Compare commits

...

5 Commits

Author SHA1 Message Date
d31b1376c8 Add graph explorer UI shell 2026-05-18 17:11:36 +02:00
d2056c9046 Complete graph explorer projection 2026-05-18 17:04:17 +02:00
91f329f878 Refresh agent instruction files 2026-05-18 16:55:51 +02:00
eec7e2a9f6 Start graph explorer contract 2026-05-18 14:08:01 +02:00
ae4f567a2b Refine interactive Fabric map workplan 2026-05-18 13:49:09 +02:00
24 changed files with 2295 additions and 76 deletions

20
.claude/rules/agents.md Normal file
View File

@@ -0,0 +1,20 @@
## Kaizen Agents
Specialized agent personas available on demand via the state-hub MCP.
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
Common agents:
| Agent | Category | When to use |
|-------|----------|-------------|
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
| `test-maintenance` | testing | Diagnose and fix failing tests |
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
| `keepaTodofile` | process | Maintain TODO.md during work |
| `project-management` | process | Track status, determine next steps |
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
All 17 agents: call `list_kaizen_agents()` for the full list.

View File

@@ -0,0 +1,8 @@
## Architecture
<!-- TODO: Describe the key design decisions and component structure.
Key modules, data flows, external integrations, state machines, etc. -->
## Quick Reference
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference

View File

@@ -0,0 +1,38 @@
## First Session Protocol
Triggered when `get_domain_summary("railiance")` shows **no workstreams**.
The project is registered but work has not yet been structured.
**Step 1 — Read, don't write**
- `~/the-custodian/canon/projects/railiance/project_charter_v0.1.md` — purpose, scope
- `~/the-custodian/canon/projects/railiance/roadmap_v0.1.md` — planned phases
- Scan repo root: README, directory structure, existing code or docs
**Step 2 — Survey in-progress work**
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
**Step 3 — Propose workstreams to Bernd**
Propose 13 workstreams — each a coherent strand, weeks to months, anchored to a
roadmap phase. **Wait for approval before creating.**
**Step 4 — Create workplan file first, then DB record (ADR-001)**
```
workplans/railiance-fabric-WP-NNNN-<slug>.md ← write this first
```
Then register in the hub:
```
create_workstream(topic_id="ca369340-a64e-442e-98f1-a4fa7dc74a38", title="...", owner="...", description="...")
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
```
**Step 5 — Record the setup**
```
add_progress_event(
summary="First session: structured railiance into N workstreams, M tasks",
event_type="milestone",
topic_id="ca369340-a64e-442e-98f1-a4fa7dc74a38",
detail={"workstreams": [...], "tasks_created": M}
)
```
<!-- Delete or archive this file once past first session -->

View File

@@ -0,0 +1,8 @@
## Repo boundary
This repo owns **railiance-fabric** only. It does not own:
<!-- TODO: List what belongs in adjacent repos, e.g.:
- SSH key management → railiance-infra/
- State hub code → state-hub/
-->

View File

@@ -0,0 +1,5 @@
**Purpose:** railiance-fabric - (fill in purpose)
**Domain:** railiance
**Repo slug:** railiance-fabric
**Topic ID:** ca369340-a64e-442e-98f1-a4fa7dc74a38

View File

@@ -0,0 +1,84 @@
## Session Protocol
State Hub: http://127.0.0.1:8000
**Step 1 — Orient**
Read the offline-safe brief first — it works without a live hub connection:
```bash
cat .custodian-brief.md
```
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
```
get_domain_summary("railiance")
```
If MCP tools are unavailable in the current agent session, use the REST API:
```bash
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
```
If the hub is offline: `cd ~/state-hub && make api`
**Step 2 — Check inbox**
With MCP tools:
```
get_messages(to_agent="railiance-fabric", unread_only=True)
```
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
requests before proceeding.
Without MCP tools:
```bash
curl -s "http://127.0.0.1:8000/messages/?to_agent=railiance-fabric&unread_only=true" \
| python3 -m json.tool
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
**Step 3 — Scan workplans**
```bash
ls workplans/
```
For each file with `status: ready`, `active`, or `blocked`, note pending
`todo`/`in_progress` tasks.
**Step 4 — Present brief**
1. **Active workstreams** for `railiance` — title, task counts, blocking decisions
2. **Pending tasks** from `workplans/` + any `[repo:railiance-fabric]` hub tasks
3. **Goal guidance** — if `goal_guidance` in summary:
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
- `alignment_warnings`: flag if active work is not aligned with current goal
4. **Suggested next action** — highest-priority open item
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
If no workstreams: follow First Session Protocol (`first-session.md`).
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
**Session close:**
With MCP tools:
```
add_progress_event(summary="...", topic_id="ca369340-a64e-442e-98f1-a4fa7dc74a38", workstream_id="<uuid>")
```
Without MCP tools:
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{"topic_id":"ca369340-a64e-442e-98f1-a4fa7dc74a38","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
```
If workplan files were modified, ensure the local copy is up to date first:
```bash
git -C <repo_path> pull --ff-only
cd ~/state-hub && make fix-consistency REPO=railiance-fabric
```
For repos where implementation runs on a remote machine (e.g. CoulombCore),
use the combined target which pulls before fixing:
```bash
cd ~/state-hub && make fix-consistency-remote REPO=railiance-fabric
```
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
until you pull — intentional to prevent clobbering remote progress.

View File

@@ -0,0 +1,19 @@
## Stack
<!-- TODO: Fill in language, frameworks, and key dependencies -->
- **Language:**
- **Key deps:**
## Dev Commands
```bash
# TODO: Fill in the standard commands for this repo
# Install dependencies
# Run tests
# Lint / type check
# Build / package (if applicable)
```

View File

@@ -0,0 +1,28 @@
## Workplan Convention (ADR-001)
File location: `workplans/railiance-fabric-WP-NNNN-<slug>.md`
ID prefix: `RAILIANCE-WP`
Work items originate as files in this repo **before** being registered in the hub.
Canonical workplan/workstream frontmatter statuses are:
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
Use `proposed` for a newly drafted plan, `ready` after review against current
repo state, and `finished` when implementation is complete. `stalled` and
`needs_review` are derived health labels, not stored statuses.
Closed workplans may be moved to `workplans/archived/` with a completion-date
prefix: `YYMMDD-railiance-fabric-WP-NNNN-<slug>.md`. The frontmatter id remains
unchanged; the prefix is only for quick visual reference.
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
directly. Promote anything requiring analysis, design, approval, dependencies, or
multiple planned phases into a normal workplan.
Ecosystem todos from other agents arrive as `[repo:railiance-fabric]` hub tasks —
visible at session start. Pick one up by creating the workplan file, then registering
the workstream.
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->

View File

@@ -2,12 +2,12 @@
## Repo Identity
**Purpose:** Railiance Fabric defines the repo-owned declaration model, validation tooling, graph queries, and State Hub export contract for the Railiance ecosystem graph.
**Purpose:** railiance-fabric - (fill in purpose)
**Domain:** railiance
**Repo slug:** railiance-fabric
**Topic ID:** `ca369340-a64e-442e-98f1-a4fa7dc74a38`
**Workplan prefix:** `RAIL-FAB-WP-`
**Workplan prefix:** `RAILIANCE-WP-`
---
@@ -82,7 +82,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
**Start:**
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
2. Check inbox: `GET /messages/?to_agent=railiance-fabric&unread_only=true`; mark read
3. Scan workplans: `ls workplans/` — note `status: active` files and open tasks
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
4. Check blocked tasks: `GET /tasks/?needs_human=true`
**During work:**
@@ -93,7 +93,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
1. Update workplan file task statuses to reflect progress
2. Log: `POST /progress/` with a summary of what changed
3. Note for the custodian operator: after workplan file changes, run from
`~/the-custodian/state-hub`:
`~/state-hub`:
```bash
make fix-consistency REPO=railiance-fabric
```
@@ -106,10 +106,10 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
Work items originate as files in this repo — not in the hub. The hub is a
read/cache/index layer that rebuilds from files.
**File location:** `workplans/RAIL-FAB-WP-NNNN-<slug>.md`
**File location:** `workplans/RAILIANCE-WP-NNNN-<slug>.md`
**Archived location:** completed workplans may move to
`workplans/archived/YYMMDD-RAIL-FAB-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
**Archived location:** finished workplans may move to
`workplans/archived/YYMMDD-RAILIANCE-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
the completion/archive date; the frontmatter `id` does not change.
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
@@ -121,12 +121,12 @@ anything needing analysis, design, approval, dependencies, or multiple phases.
```yaml
---
id: RAIL-FAB-WP-NNNN
id: RAILIANCE-WP-NNNN
type: workplan
title: "..."
domain: railiance
repo: railiance-fabric
status: active | done
status: proposed | ready | active | blocked | backlog | finished | archived
owner: codex
topic_slug: ...
created: "YYYY-MM-DD"
@@ -135,13 +135,17 @@ state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
---
```
Use `proposed` for a new draft, `ready` after review against current repo
state, and `finished` after implementation. `stalled` and `needs_review` are
derived health labels, not frontmatter statuses.
**Task block format** (one per `##` section):
```
## Task Title
` ` `task
id: RAIL-FAB-WP-NNNN-T01
id: RAILIANCE-WP-NNNN-T01
status: todo | in_progress | done | blocked
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit

11
CLAUDE.md Normal file
View File

@@ -0,0 +1,11 @@
# railiance-fabric — Claude Code Instructions
@SCOPE.md
@.claude/rules/repo-identity.md
@.claude/rules/session-protocol.md
@.claude/rules/first-session.md
@.claude/rules/workplan-convention.md
@.claude/rules/stack-and-commands.md
@.claude/rules/architecture.md
@.claude/rules/repo-boundary.md
@.claude/rules/agents.md

View File

@@ -35,6 +35,7 @@ railiance-fabric unresolved
railiance-fabric blast-radius openbao-kv-v2-mount
railiance-fabric export --format json
railiance-fabric export --format mermaid
railiance-fabric export --format graph-explorer
```
See `docs/discovery-queries.md` for command details.
@@ -94,7 +95,15 @@ GET /repositories/{repo_slug}/inventory
GET /repositories/{repo_slug}/snapshots
GET /repositories/{repo_slug}/snapshots/diff
GET /search?q=jsonschema
GET /ui/graph-explorer
GET /exports/graph-explorer/manifest
GET /exports/graph-explorer
```
See `docs/registry-onboarding.md` for the multi-repo manifest and operating
loop.
The graph explorer export is the first executable slice of the interactive
Fabric map. See `docs/graph-explorer-transfer-review.md` for the repo-scoping
transfer review and `docs/graph-explorer-contract.md` for the shared manifest
and payload contract.

View File

@@ -78,8 +78,19 @@ Export the graph as Mermaid:
railiance-fabric export --format mermaid
```
Export the graph as the manifest-compatible graph explorer payload:
```bash
railiance-fabric export --format graph-explorer
```
The JSON export has two top-level arrays:
- `nodes`: service, capability, interface, dependency, and binding nodes
- `edges`: graph relationships such as `provides`, `exposes`,
`available_via`, `consumes`, `binds:<status>`, and `uses_interface`
The graph explorer payload wraps those nodes and edges as Cytoscape-compatible
elements with stable keys, layers, display state, visual facets, source
references, and deep links. The registry service exposes the same projection at
`GET /exports/graph-explorer`.

View File

@@ -0,0 +1,152 @@
# Graph Explorer Contract
This note defines the first manifest and payload contract for the interactive
Fabric map and the possible reusable graph explorer engine.
The contract is intentionally host-neutral. Fabric and repo-scoping should be
able to use the same interaction shell by exposing a manifest and a graph
payload with stable fields.
## Files
- `schemas/graph-explorer-manifest.schema.yaml` validates a host manifest.
- `schemas/graph-explorer-payload.schema.yaml` validates graph payloads.
- `railiance_fabric.graph_explorer` provides the first Fabric registry
manifest and payload projection.
## Registry Endpoints
The registry service exposes the first Fabric projection:
```text
GET /ui/graph-explorer
GET /exports/graph-explorer/manifest
GET /exports/graph-explorer
```
The local CLI can emit the same payload for repo-local inspection:
```bash
railiance-fabric export --format graph-explorer
```
The manifest tells a graph shell where to load data, which fields are stable,
which layers exist, which filter fields are available, and which modes the host
supports.
The payload is compatible with Cytoscape-style element arrays:
```json
{
"apiVersion": "railiance.fabric/v1alpha1",
"kind": "GraphExplorerPayload",
"manifest_id": "railiance-fabric.registry-map",
"mode": "full",
"elements": [
{
"data": {
"id": "repo:railiance-fabric",
"stableKey": "repo:railiance-fabric",
"kind": "Repository",
"layer": "repository",
"label": "Railiance Fabric",
"displayState": "show"
}
}
],
"hidden_elements": []
}
```
## Required Payload Semantics
Every element must include:
- `id`: the current element id used by the graph library.
- `stableKey`: the durable id used by profile rules, manual overrides, layout
state, and deep links.
- `kind`: host-specific entity kind.
- `layer`: host-declared layer used for layout, grouping, and color.
- `displayState`: one of `show`, `blur`, or `hide`.
Edges are ordinary elements whose data also includes:
- `source`
- `target`
- `edgeType`
- `strength`
- `sameLayer`
Hosts should also include useful optional fields when available: `label`,
`name`, `description`, `repo`, `domain`, `lifecycle`, `reviewState`,
`freshnessState`, `confidence`, `visualSize`, `ownership`, `unresolved`,
`sourceReferences`, and `deepLinks`.
## Display State Ownership
The contract allows either the host service or the engine to evaluate display
state.
The precedence rule is fixed:
1. Default element state is `show`.
2. Rules are applied in list order; later matching rules override earlier
matching rules.
3. Manual overrides win last.
4. Edges connected to hidden nodes are hidden.
5. Edges connected to blurred nodes may carry a contextual muted class or data
hint.
Repo-scoping currently evaluates this host-side. A future extracted engine can
evaluate it client-side for static exports, but host-provided `displayState`
must remain valid input.
## Fabric Layers
The first Fabric manifest declares:
| Layer | Purpose |
|-------|---------|
| `repository` | Registered source repositories, including registered-only repos. |
| `service` | Service declarations. |
| `capability` | Capability declarations. |
| `interface` | Interface declarations. |
| `dependency` | Dependency declarations, including unresolved dependency nodes. |
| `binding` | Binding assertions between consumer dependencies and providers. |
| `library` | Future library/SBOM inventory nodes. |
## Repo-Scoping Compatibility
Repo-scoping can adapt without a rewrite because its current graph payload
already exposes most required fields:
- `id`, `stableKey`, `kind`, `layer`, labels, and metadata-rich data objects.
- `displayState`, `visibilitySource`, and `visibilityReason`.
- edge `source`, `target`, `dependencyType`, `strength`, `sameLayer`, and
visual width.
- profile data, filter rules, manual overrides, hidden elements, and orphaned
overrides.
The main adapter work is manifest generation and small field aliases:
`dependencyType` can map to `edgeType`, repo-scoping layers become manifest
layers, and existing profile endpoints can be listed under manifest
`endpoints.profiles`.
## Extraction Boundary
The extracted `graph-explorer-engine` should own:
- graph rendering and layout controls
- filter and manual override UI
- hover popups and selection detail panels
- profile UI when the host declares profile endpoints
- URL state and copied state blobs
- schema definitions and compatibility tests
Host repos should own:
- graph projection and metadata enrichment
- profile persistence
- authentication and authorization
- domain-specific graph modes
- deep links back to source systems

View File

@@ -0,0 +1,84 @@
# Graph Explorer Transfer Review
This note closes the first implementation step for `RAIL-FAB-WP-0008-T01`.
It reviews the repo-scoping dependency graph work from `RREG-WP-0010` and
`RREG-WP-0011` and turns the transferable parts into Fabric requirements.
## Source Implementation
Repo-scoping already has a useful graph explorer shape:
- `docs/adr-dependency-graph-visualization-framework.md` chooses Cytoscape.js
because the required interaction model is graph-native: pan, zoom, selection,
layouts, styling, filtering, and path exploration.
- `docs/dependency-visualization-exploration.md` defines layered graph data,
`show` / `blur` / `hide` display states, deterministic filters, manual
overrides, and saved view profiles.
- `src/repo_scoping/core/service.py` emits Cytoscape-compatible payloads with
stable keys, metadata-rich node and edge data, visibility state, hidden
elements, profile application, confidence sizing, and edge strength sizing.
- `src/repo_scoping/web_ui/views.py` provides the first UI shell: profile
controls, structured filters, manual overrides, focus depth, hover popups,
selection details, and a layered layout.
- `tests/test_web_api.py` verifies graph endpoints, ad hoc filters, profile
creation, duplicate profiles, latest-profile defaulting, and UI wiring.
## Reusable Behaviors
Fabric should carry these behaviors into the shared graph explorer contract:
- Cytoscape-compatible element arrays with `data` and optional `classes`.
- Stable element keys that survive refreshes, so layout state, profile rules,
and deep links do not churn.
- Metadata-rich nodes and edges rather than UI-only labels.
- Explicit `layer`, `kind`, `reviewState`, `freshnessState`, and
`displayState` fields.
- Visibility actions of `show`, `blur`, and `hide`, with later rules overriding
earlier rules and manual overrides winning last.
- Hidden element reporting, so over-filtered views are recoverable.
- Edge treatment for context-muted nodes.
- View profiles that can persist filter rules and manual overrides when a host
provides profile endpoints.
- Hover popups for compact inspection and selection panels for full detail.
- Layout modes for full graph, impact or filtered graph, selected path, and
neighborhood focus.
- Visual hints for confidence, strength, same-layer edges, stale or unresolved
state, and review state.
## Adapter-Owned Semantics
These repo-scoping concepts should remain in the repo-scoping adapter and not
be hard-coded into the engine:
- Layer names of `fact`, `evidence`, `feature`, `capability`, `ability`, and
`scope`.
- Candidate graph approval semantics.
- Dependency impact analysis over base and target analysis runs.
- Document fact normalization and README/SCOPE de-duplication.
- Scope curation recommendations.
Fabric has its own adapter semantics:
- Layer names of `repository`, `service`, `capability`, `interface`,
`dependency`, `binding`, and `library`.
- Registered-only repositories without accepted graph snapshots.
- State Hub repo ids, workplan links, and registry endpoints as deep links.
- Unresolved dependencies where provider bindings are missing or disputed.
- Blast-radius and provider-chain exploration across accepted Fabric
declarations.
## Extraction Recommendation
Start in `railiance-fabric` with a host-neutral manifest and payload contract,
plus a Fabric registry projection. The first UI shell can live locally while
the contract is still moving.
Extract into a separate repository only after two host adapters are proven:
1. Fabric registry map adapter.
2. Repo-scoping dependency graph adapter.
The likely extracted repository is `graph-explorer-engine`. It should own the
static interaction shell, schema definitions, layout/filter/profile client
logic, and adapter manifest contract. Host repos should keep graph projection,
profile persistence, authentication, and domain-specific deep links.

View File

@@ -64,4 +64,7 @@ GET /exports/state-hub
GET /exports/backstage
GET /exports/xregistry
GET /exports/libraries/xregistry
GET /ui/graph-explorer
GET /exports/graph-explorer/manifest
GET /exports/graph-explorer
```

View File

@@ -12,6 +12,7 @@ from pathlib import Path
from .loader import declaration_files, load_yaml
from .graph import FabricGraph, build_graph
from .graph_explorer import fabric_graph_explorer_payload
from .validation import validate_roots
@@ -57,9 +58,9 @@ def build_parser() -> argparse.ArgumentParser:
blast.add_argument("interface", help="Interface type or interface declaration id.")
blast.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
export = sub.add_parser("export", help="Export graph as JSON or Mermaid.")
export = sub.add_parser("export", help="Export graph as JSON, Mermaid, or graph-explorer payload.")
export.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
export.add_argument("--format", choices=["json", "mermaid"], default="json")
export.add_argument("--format", choices=["json", "mermaid", "graph-explorer"], default="json")
registry = sub.add_parser("registry", help="Feed a running Railiance Fabric registry service.")
registry_sub = registry.add_subparsers(dest="registry_command", required=True)
@@ -131,7 +132,12 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "export":
graph = _load_graph_or_exit(args.paths)
print(graph.to_mermaid() if args.format == "mermaid" else graph.to_json())
if args.format == "mermaid":
print(graph.to_mermaid())
elif args.format == "graph-explorer":
print(json.dumps(fabric_graph_explorer_payload(graph.to_export()), indent=2, sort_keys=True))
else:
print(graph.to_json())
return 0
if args.command == "registry":

View File

@@ -267,13 +267,16 @@ def _escape_mermaid(value: str) -> str:
def _export_attributes(declaration: Declaration) -> dict[str, Any]:
spec = declaration.spec
base = _base_export_attributes(declaration)
if declaration.kind == "ServiceDeclaration":
return {
**base,
"provides_capabilities": list(spec.get("provides_capabilities", [])),
"exposes_interfaces": list(spec.get("exposes_interfaces", [])),
}
if declaration.kind == "CapabilityDeclaration":
return {
**base,
"capability_type": spec.get("capability_type", ""),
"service_id": spec.get("service_id", ""),
"interface_ids": list(spec.get("interface_ids", [])),
@@ -281,6 +284,7 @@ def _export_attributes(declaration: Declaration) -> dict[str, Any]:
}
if declaration.kind == "InterfaceDeclaration":
return {
**base,
"interface_type": spec.get("interface_type", ""),
"service_id": spec.get("service_id", ""),
"capability_ids": list(spec.get("capability_ids", [])),
@@ -291,6 +295,7 @@ def _export_attributes(declaration: Declaration) -> dict[str, Any]:
requires = spec.get("requires", {})
interface = spec.get("interface", {})
return {
**base,
"consumer_service_id": spec.get("consumer_service_id", ""),
"requires_capability_id": requires.get("capability_id", ""),
"requires_capability_type": requires.get("capability_type", ""),
@@ -300,9 +305,20 @@ def _export_attributes(declaration: Declaration) -> dict[str, Any]:
}
if declaration.kind == "BindingAssertion":
return {
**base,
"dependency_id": spec.get("dependency_id", ""),
"provider_capability_id": spec.get("provider_capability_id", ""),
"provider_interface_id": spec.get("provider_interface_id", ""),
"status": spec.get("status", ""),
}
return {}
return base
def _base_export_attributes(declaration: Declaration) -> dict[str, Any]:
source_links = declaration.metadata.get("source_links", [])
return {
"owner": declaration.metadata.get("owner", ""),
"description": declaration.spec.get("description", ""),
"source_path": str(declaration.path),
"source_links": source_links if isinstance(source_links, list) else [],
}

View File

@@ -0,0 +1,521 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
DISPLAY_STATES = ("show", "blur", "hide")
LAYER_ORDER = (
"repository",
"service",
"capability",
"interface",
"dependency",
"binding",
"library",
)
_KIND_LAYER = {
"Repository": "repository",
"ServiceDeclaration": "service",
"CapabilityDeclaration": "capability",
"InterfaceDeclaration": "interface",
"DependencyDeclaration": "dependency",
"BindingAssertion": "binding",
"Library": "library",
}
_LAYER_COLORS = {
"repository": "#475569",
"service": "#0f766e",
"capability": "#2563eb",
"interface": "#7c3aed",
"dependency": "#b45309",
"binding": "#be123c",
"library": "#0891b2",
}
_EDGE_STRENGTH = {
"provides": "strong",
"exposes": "strong",
"available_via": "medium",
"consumes": "medium",
"uses_interface": "medium",
"declares": "weak",
}
def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]:
"""Return the host manifest for the reusable graph explorer shell."""
return {
"apiVersion": "railiance.fabric/v1alpha1",
"kind": "GraphExplorerManifest",
"id": "railiance-fabric.registry-map",
"title": "Railiance Fabric Registry Map",
"description": "Manifest for exploring registered Fabric ecosystem entities.",
"engine": {
"suggested_package": "graph-explorer-engine",
"preferred_library": "cytoscape",
"display_state_owner": "host-or-engine",
},
"endpoints": {
"graph": {
"method": "GET",
"url": f"{base_url}/exports/graph-explorer",
},
"manifest": {
"method": "GET",
"url": f"{base_url}/exports/graph-explorer/manifest",
},
},
"identity": {
"node_id_field": "id",
"stable_key_field": "stableKey",
"edge_id_field": "id",
"source_field": "source",
"target_field": "target",
},
"layers": [
{
"id": layer,
"label": layer.replace("_", " ").title(),
"order": index,
"color": _LAYER_COLORS[layer],
}
for index, layer in enumerate(LAYER_ORDER)
],
"grouping_fields": ["domain", "repo", "layer", "kind", "lifecycle", "unresolved"],
"search_fields": [
"id",
"stableKey",
"label",
"name",
"description",
"repo",
"domain",
"kind",
"layer",
"edgeType",
],
"filter": {
"actions": list(DISPLAY_STATES),
"fields": [
{"id": "kind", "label": "Kind", "type": "string"},
{"id": "layer", "label": "Layer", "type": "string"},
{"id": "repo", "label": "Repo", "type": "string"},
{"id": "domain", "label": "Domain", "type": "string"},
{"id": "lifecycle", "label": "Lifecycle", "type": "string"},
{"id": "reviewState", "label": "Review State", "type": "string"},
{"id": "unresolved", "label": "Unresolved", "type": "boolean"},
{"id": "edgeType", "label": "Edge Type", "type": "string"},
{"id": "strength", "label": "Strength", "type": "string"},
{"id": "sameLayer", "label": "Same Layer", "type": "boolean"},
{"id": "text", "label": "Text", "type": "string"},
],
},
"visual_encodings": {
"node_color": "layer",
"node_shape": "kind",
"node_size": "confidence",
"edge_width": "strength",
"edge_style": "sameLayer",
"edge_opacity": "displayState",
},
"detail": {
"node_fields": [
"name",
"kind",
"layer",
"repo",
"domain",
"lifecycle",
"unresolved",
"description",
"sourceReferences",
"deepLinks",
],
"edge_fields": [
"edgeType",
"strength",
"source",
"target",
"sourceLayer",
"targetLayer",
"sameLayer",
"deepLinks",
],
},
"modes": [
{
"id": "full",
"label": "Full",
"description": "Show the complete registered ecosystem graph.",
},
{
"id": "selected-path",
"label": "Selected Path",
"description": "Show selected nodes with predecessor and successor context.",
"requires_selection": True,
},
{
"id": "neighborhood",
"label": "Neighborhood",
"description": "Show the selected node neighborhood by configurable depth.",
"requires_selection": True,
},
{
"id": "onboarding-gaps",
"label": "Onboarding Gaps",
"description": "Highlight registered repos without accepted Fabric graph snapshots.",
},
{
"id": "unresolved",
"label": "Unresolved",
"description": "Highlight dependencies that have no accepted provider binding.",
},
],
"profile_persistence": "none",
"shareable_state": {
"url_parameters": True,
"profile_id": False,
"state_blob": True,
},
}
def fabric_graph_explorer_payload(
graph: dict[str, Any],
repositories: list[dict[str, Any]] | None = None,
snapshot_repo_slugs: set[str] | None = None,
) -> dict[str, Any]:
"""Project a Fabric graph export into the shared graph explorer payload."""
source_nodes = [node for node in graph.get("nodes", []) if isinstance(node, dict)]
source_edges = [edge for edge in graph.get("edges", []) if isinstance(edge, dict)]
repositories = repositories or []
snapshot_repo_slugs = snapshot_repo_slugs or set()
layers_by_id = {
str(node.get("id", "")): _layer_for_kind(str(node.get("kind", "")))
for node in source_nodes
}
source_repo_slugs = {
str(node.get("repo", "")).strip()
for node in source_nodes
if str(node.get("repo", "")).strip()
}
registered_repo_slugs = {
str(repo.get("slug", "")).strip()
for repo in repositories
if str(repo.get("slug", "")).strip()
}
repo_slugs = set(source_repo_slugs)
repo_slugs.update(registered_repo_slugs)
repo_slugs.update(snapshot_repo_slugs)
repo_slugs.discard("")
unresolved = _unresolved_dependency_ids(source_nodes, source_edges)
elements: list[dict[str, Any]] = []
repository_index = {str(repo.get("slug", "")): repo for repo in repositories}
for slug in sorted(repo_slugs):
repo = repository_index.get(slug, {})
has_snapshot = slug in snapshot_repo_slugs or slug in source_repo_slugs
is_registered = slug in registered_repo_slugs
repo_id = f"repo:{slug}"
elements.append(
{
"data": {
"id": repo_id,
"stableKey": repo_id,
"kind": "Repository",
"layer": "repository",
"label": str(repo.get("name") or slug),
"name": str(repo.get("name") or slug),
"description": (
"Registered repository with accepted Fabric graph snapshot."
if has_snapshot
else "Registered repository without an accepted Fabric graph snapshot."
),
"repo": slug,
"domain": "railiance",
"lifecycle": "active" if has_snapshot else "registered-only",
"reviewState": "accepted" if has_snapshot else "candidate",
"freshnessState": "current" if has_snapshot else "missing",
"unresolved": is_registered and not has_snapshot,
"confidence": 1.0 if has_snapshot else 0.3,
"visualSize": 56 if has_snapshot else 42,
"ownership": "registry",
"displayState": "show",
"visibilitySource": "default",
"visibilityReason": "default",
"sourceReferences": [],
"deepLinks": _repository_links(repo),
},
"classes": "repository accepted" if has_snapshot else "repository candidate unresolved",
}
)
for node in source_nodes:
node_id = str(node.get("id", ""))
if not node_id:
continue
kind = str(node.get("kind", ""))
layer = _layer_for_kind(kind)
is_unresolved = node_id in unresolved
attributes = node.get("attributes") if isinstance(node.get("attributes"), dict) else {}
elements.append(
{
"data": {
"id": node_id,
"stableKey": node_id,
"kind": kind,
"layer": layer,
"label": str(node.get("name") or node_id),
"name": str(node.get("name") or node_id),
"description": _node_description(kind, attributes),
"repo": str(node.get("repo", "")),
"domain": str(node.get("domain", "")),
"lifecycle": str(node.get("lifecycle", "")),
"reviewState": "accepted",
"freshnessState": "current",
"unresolved": is_unresolved,
"confidence": 0.45 if is_unresolved else 1.0,
"visualSize": 34 if layer == "binding" else 46 if is_unresolved else 50,
"ownership": str(attributes.get("owner") or "repo"),
"attributes": attributes,
"displayState": "show",
"visibilitySource": "default",
"visibilityReason": "default",
"sourceReferences": _source_references(node),
"deepLinks": _node_links(node_id, attributes),
},
"classes": " ".join(
part
for part in (layer, kind, "unresolved" if is_unresolved else "accepted")
if part
),
}
)
edge_index = 0
for edge in source_edges:
source = str(edge.get("from", ""))
target = str(edge.get("to", ""))
edge_type = str(edge.get("type", ""))
if not source or not target:
continue
source_layer = layers_by_id.get(source, "unknown")
target_layer = layers_by_id.get(target, "unknown")
strength = _edge_strength(edge_type)
edge_id = f"edge:{edge_index}:{source}:{edge_type}:{target}"
edge_index += 1
elements.append(
{
"data": {
"id": edge_id,
"stableKey": edge_id,
"kind": "edge",
"layer": "relationship",
"label": edge_type,
"source": source,
"target": target,
"sourceLayer": source_layer,
"targetLayer": target_layer,
"edgeType": edge_type,
"dependencyType": edge_type,
"strength": strength,
"edgeWidth": _edge_width(strength),
"sameLayer": source_layer == target_layer,
"reviewState": "accepted",
"freshnessState": "current",
"displayState": "show",
"visibilitySource": "default",
"visibilityReason": "default",
"deepLinks": {},
},
"classes": " ".join(
part
for part in (
edge_type.replace(":", "-"),
strength,
"same-layer" if source_layer == target_layer else "",
)
if part
),
}
)
for slug in sorted(repo_slugs):
repo_id = f"repo:{slug}"
for node in source_nodes:
node_id = str(node.get("id", ""))
if str(node.get("repo", "")) != slug or not node_id:
continue
edge_id = f"edge:{edge_index}:{repo_id}:declares:{node_id}"
edge_index += 1
target_layer = layers_by_id.get(node_id, "unknown")
elements.append(
{
"data": {
"id": edge_id,
"stableKey": edge_id,
"kind": "edge",
"layer": "relationship",
"label": "declares",
"source": repo_id,
"target": node_id,
"sourceLayer": "repository",
"targetLayer": target_layer,
"edgeType": "declares",
"dependencyType": "declares",
"strength": "weak",
"edgeWidth": 1,
"sameLayer": False,
"reviewState": "accepted",
"freshnessState": "current",
"displayState": "show",
"visibilitySource": "default",
"visibilityReason": "default",
"deepLinks": {},
},
"classes": "declares weak",
}
)
visible_nodes = [element for element in elements if "source" not in element["data"]]
visible_edges = [element for element in elements if "source" in element["data"]]
return {
"apiVersion": "railiance.fabric/v1alpha1",
"kind": "GraphExplorerPayload",
"manifest_id": "railiance-fabric.registry-map",
"generated_at": _utc_now(),
"repository": {"slug": "registry", "name": "Railiance Fabric Registry"},
"mode": "full",
"profile": None,
"metrics": {
"node_count": len(visible_nodes),
"edge_count": len(visible_edges),
"hidden_count": 0,
"blurred_count": 0,
"registered_repo_count": len(registered_repo_slugs),
"repo_node_count": len(repo_slugs),
"registered_only_repo_count": sum(
1
for element in visible_nodes
if element["data"].get("repo") in registered_repo_slugs
and element["data"].get("lifecycle") == "registered-only"
),
"unresolved_count": len(unresolved),
},
"filter": {
"rules": [],
"manual_overrides": {},
"orphaned_overrides": [],
"precedence": "later rules override earlier rules; manual overrides win last",
"connected_edge_behavior": "edges connected to hidden nodes are hidden",
},
"elements": elements,
"hidden_elements": [],
}
def _utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _layer_for_kind(kind: str) -> str:
return _KIND_LAYER.get(kind, kind.lower() or "unknown")
def _edge_strength(edge_type: str) -> str:
if edge_type.startswith("binds:"):
status = edge_type.split(":", 1)[1]
if status in {"missing", "disputed"}:
return "weak"
if status in {"accepted", "exact"}:
return "strong"
return "medium"
return _EDGE_STRENGTH.get(edge_type, "medium")
def _edge_width(strength: str) -> int:
return {"weak": 1, "medium": 3, "strong": 5}.get(strength, 2)
def _unresolved_dependency_ids(
nodes: list[dict[str, Any]],
edges: list[dict[str, Any]],
) -> set[str]:
dependency_ids = {
str(node.get("id", ""))
for node in nodes
if node.get("kind") == "DependencyDeclaration" and str(node.get("id", ""))
}
resolved: set[str] = set()
unresolved: set[str] = set()
for edge in edges:
edge_type = str(edge.get("type", ""))
source = str(edge.get("from", ""))
if source not in dependency_ids or not edge_type.startswith("binds:"):
continue
status = edge_type.split(":", 1)[1]
if status in {"accepted", "candidate", "exact", "compatible"}:
resolved.add(source)
if status in {"missing", "disputed"}:
unresolved.add(source)
return (dependency_ids - resolved) | unresolved
def _node_description(kind: str, attributes: object) -> str:
if not isinstance(attributes, dict):
return ""
description = str(attributes.get("description", ""))
if description:
return description
if kind == "CapabilityDeclaration":
return str(attributes.get("capability_type", ""))
if kind == "InterfaceDeclaration":
version = str(attributes.get("version", ""))
interface_type = str(attributes.get("interface_type", ""))
return " ".join(part for part in (interface_type, version) if part)
if kind == "DependencyDeclaration":
return str(
attributes.get("requires_capability_type")
or attributes.get("requires_capability_id")
or attributes.get("interface_type")
or ""
)
if kind == "BindingAssertion":
return str(attributes.get("status", ""))
return ""
def _source_references(node: dict[str, Any]) -> list[dict[str, str]]:
attributes = node.get("attributes")
references: list[dict[str, str]] = []
if isinstance(attributes, dict):
source_path = str(attributes.get("source_path") or "")
if source_path:
references.append({"label": "Declaration", "path": source_path})
for source in attributes.get("source_links", []):
if isinstance(source, dict):
references.append({key: str(value) for key, value in source.items()})
return references
def _node_links(node_id: str, attributes: dict[str, Any] | None = None) -> dict[str, str]:
links = {"registry": f"/graph/nodes/{node_id}"}
source_path = str((attributes or {}).get("source_path") or "")
if source_path:
links["sourceFile"] = source_path
return links
def _repository_links(repository: dict[str, Any]) -> dict[str, str]:
slug = str(repository.get("slug", ""))
links = {"registry": f"/repositories/{slug}"} if slug else {}
state_hub_repo_id = str(repository.get("state_hub_repo_id") or "")
if state_hub_repo_id:
links["stateHubRepo"] = f"/repos/by-id/{state_hub_repo_id}"
return links

View File

@@ -0,0 +1,364 @@
from __future__ import annotations
def graph_explorer_page() -> str:
return """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fabric Map</title>
<style>
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--bg: #f7f8fa;
--panel: #ffffff;
--line: #d6dce5;
--text: #172033;
--muted: #667085;
--accent: #0f766e;
--accent-2: #2563eb;
--warn: #b45309;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body { background: var(--bg); color: var(--text); font-size: 14px; }
.shell { display: grid; grid-template-rows: auto 1fr; height: 100vh; min-height: 620px; }
.toolbar {
display: grid;
grid-template-columns: minmax(180px, 1.2fr) repeat(3, minmax(120px, .6fr)) auto auto auto;
gap: 8px;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
.main { display: grid; grid-template-columns: minmax(0, 1fr) 340px; min-height: 0; }
.canvas-wrap { position: relative; min-width: 0; min-height: 0; }
#graph-canvas { position: absolute; inset: 0; }
.side {
border-left: 1px solid var(--line);
background: var(--panel);
min-height: 0;
overflow: auto;
padding: 14px;
}
.side h1 { font-size: 17px; line-height: 1.2; margin: 0 0 10px; }
.section { border-top: 1px solid var(--line); padding-top: 12px; margin-top: 12px; }
.section:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
label { color: var(--muted); display: grid; gap: 4px; font-size: 12px; }
input, select, button {
border: 1px solid var(--line);
border-radius: 6px;
color: var(--text);
font: inherit;
min-height: 34px;
padding: 6px 8px;
background: #ffffff;
}
button {
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
white-space: nowrap;
}
button.primary { background: var(--accent); border-color: var(--accent); color: #ffffff; }
button:disabled { color: #98a2b3; cursor: default; }
.button-row { display: flex; flex-wrap: wrap; gap: 8px; }
.meta { color: var(--muted); font-size: 12px; margin: 0; }
.pill {
display: inline-flex;
align-items: center;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
min-height: 24px;
padding: 2px 8px;
margin: 0 4px 4px 0;
font-size: 12px;
}
.detail-list { display: grid; gap: 6px; margin: 8px 0 0; padding: 0; list-style: none; }
.detail-list li { overflow-wrap: anywhere; }
.popup {
position: absolute;
z-index: 4;
max-width: 260px;
display: none;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
box-shadow: 0 16px 40px rgba(23, 32, 51, .14);
padding: 10px;
pointer-events: none;
}
.popup strong { display: block; margin-bottom: 4px; }
.notice { color: var(--warn); padding: 14px; }
@media (max-width: 900px) {
.shell { min-height: 760px; }
.toolbar { grid-template-columns: 1fr 1fr; }
.main { grid-template-columns: 1fr; grid-template-rows: minmax(420px, 1fr) 330px; }
.side { border-left: 0; border-top: 1px solid var(--line); }
}
</style>
</head>
<body>
<section class="shell">
<div class="toolbar">
<label>Search <input id="search" autocomplete="off"></label>
<label>Layer <select id="layer-filter"><option value="">Any</option></select></label>
<label>Review <select id="review-filter"><option value="">Any</option><option>accepted</option><option>candidate</option></select></label>
<label>Unresolved <select id="unresolved-filter"><option value="">Any</option><option value="true">Only</option></select></label>
<button type="button" data-action="fit">Fit</button>
<button type="button" data-action="focus">Focus</button>
<button type="button" data-action="clear" class="primary">Reset</button>
<span id="counts" class="meta"></span>
</div>
<main class="main">
<div class="canvas-wrap">
<div id="graph-canvas" role="img" aria-label="Interactive Fabric graph"></div>
<div id="popup" class="popup"></div>
</div>
<aside class="side">
<section class="section">
<h1 id="detail-title">Fabric Map</h1>
<p id="detail-summary" class="meta">No selection</p>
<div id="detail-pills"></div>
<ul id="detail-list" class="detail-list"></ul>
</section>
<section class="section">
<div class="button-row">
<button type="button" data-override="show">Show</button>
<button type="button" data-override="blur">Blur</button>
<button type="button" data-override="hide">Hide</button>
<button type="button" data-action="reset-overrides">Clear Overrides</button>
</div>
</section>
<section class="section">
<p id="hidden-summary" class="meta">Hidden 0</p>
<div id="legend"></div>
</section>
</aside>
</main>
</section>
<script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js"></script>
<script>
(() => {
const manifestUrl = "/exports/graph-explorer/manifest";
const graphUrl = "/exports/graph-explorer";
const canvas = document.getElementById("graph-canvas");
const popup = document.getElementById("popup");
const searchInput = document.getElementById("search");
const layerFilter = document.getElementById("layer-filter");
const reviewFilter = document.getElementById("review-filter");
const unresolvedFilter = document.getElementById("unresolved-filter");
const counts = document.getElementById("counts");
const hiddenSummary = document.getElementById("hidden-summary");
const detailTitle = document.getElementById("detail-title");
const detailSummary = document.getElementById("detail-summary");
const detailPills = document.getElementById("detail-pills");
const detailList = document.getElementById("detail-list");
const legend = document.getElementById("legend");
let cy = null;
let selected = null;
let focusSet = null;
let manualOverrides = {};
let layerColors = {};
const escapeHtml = (value) => String(value ?? "")
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
const elementText = (data) => [
data.id, data.stableKey, data.label, data.name, data.description,
data.repo, data.domain, data.kind, data.layer, data.edgeType
].join(" ").toLowerCase();
const matchesFilters = (element) => {
const data = element.data();
const text = searchInput.value.trim().toLowerCase();
if (text && !elementText(data).includes(text)) return false;
if (layerFilter.value && data.layer !== layerFilter.value) return false;
if (reviewFilter.value && data.reviewState !== reviewFilter.value) return false;
if (unresolvedFilter.value === "true" && data.unresolved !== true) return false;
if (focusSet && !focusSet.has(element.id())) return false;
return true;
};
const applyFilters = () => {
if (!cy) return;
const hiddenNodes = new Set();
cy.nodes().forEach((node) => {
let state = matchesFilters(node) ? "show" : "hide";
if (manualOverrides[node.id()]) state = manualOverrides[node.id()];
node.data("displayState", state);
node.toggleClass("display-blur", state === "blur");
node.style("display", state === "hide" ? "none" : "element");
if (state === "hide") hiddenNodes.add(node.id());
});
cy.edges().forEach((edge) => {
let state = matchesFilters(edge) ? "show" : "hide";
if (hiddenNodes.has(edge.data("source")) || hiddenNodes.has(edge.data("target"))) state = "hide";
if (manualOverrides[edge.id()]) state = manualOverrides[edge.id()];
edge.data("displayState", state);
edge.toggleClass("display-blur", state === "blur");
edge.style("display", state === "hide" ? "none" : "element");
});
const visibleNodes = cy.nodes().filter((node) => node.style("display") !== "none").length;
const visibleEdges = cy.edges().filter((edge) => edge.style("display") !== "none").length;
const hidden = cy.elements().length - visibleNodes - visibleEdges;
counts.textContent = `${visibleNodes} nodes / ${visibleEdges} edges`;
hiddenSummary.textContent = `Hidden ${hidden}`;
};
const showDetails = (element) => {
selected = element || null;
if (!element) {
detailTitle.textContent = "Fabric Map";
detailSummary.textContent = "No selection";
detailPills.innerHTML = "";
detailList.innerHTML = "";
return;
}
const data = element.data();
detailTitle.textContent = data.name || data.label || data.id;
detailSummary.textContent = data.description || data.id;
detailPills.innerHTML = ["kind", "layer", "repo", "reviewState", "displayState"]
.map((field) => data[field] ? `<span class="pill">${escapeHtml(data[field])}</span>` : "")
.join("");
const links = data.deepLinks || {};
const refs = data.sourceReferences || [];
const rows = [
["id", data.id],
["source", data.source],
["target", data.target],
["edge", data.edgeType],
["strength", data.strength],
...Object.entries(links),
...refs.map((ref) => [ref.label || ref.kind || "source", ref.path || ref.url || ref.ref || ""])
];
detailList.innerHTML = rows
.filter(([, value]) => value)
.map(([key, value]) => `<li><strong>${escapeHtml(key)}</strong> ${escapeHtml(value)}</li>`)
.join("");
};
const applyFocus = () => {
if (!cy || !selected) return;
const collection = selected.union(selected.neighborhood()).union(selected.neighborhood().neighborhood());
focusSet = new Set(collection.map((item) => item.id()));
applyFilters();
};
const renderLegend = (layers) => {
legend.innerHTML = layers.map((layer) =>
`<span class="pill"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${escapeHtml(layer.color || "#667085")}"></span>${escapeHtml(layer.label)}</span>`
).join("");
};
const boot = async () => {
const [manifest, payload] = await Promise.all([
fetch(manifestUrl).then((response) => response.json()),
fetch(graphUrl).then((response) => response.json())
]);
layerColors = Object.fromEntries((manifest.layers || []).map((layer) => [layer.id, layer.color]));
(manifest.layers || []).forEach((layer) => {
const option = document.createElement("option");
option.value = layer.id;
option.textContent = layer.label;
layerFilter.appendChild(option);
});
renderLegend(manifest.layers || []);
const elements = (payload.elements || []).map((element) => ({
...element,
data: {...element.data, color: layerColors[element.data.layer] || "#667085"}
}));
cy = cytoscape({
container: canvas,
elements,
layout: {name: "cose", animate: false, randomize: false},
style: [
{selector: "node", style: {
"background-color": "data(color)",
"border-color": "#172033",
"border-width": 1,
"color": "#172033",
"font-size": 11,
"height": "data(visualSize)",
"label": "data(label)",
"text-background-color": "#ffffff",
"text-background-opacity": .85,
"text-background-padding": 2,
"text-wrap": "wrap",
"text-max-width": 130,
"width": "data(visualSize)"
}},
{selector: "edge", style: {
"curve-style": "bezier",
"line-color": "#98a2b3",
"target-arrow-color": "#98a2b3",
"target-arrow-shape": "triangle",
"width": "data(edgeWidth)"
}},
{selector: "edge[strength = 'strong']", style: {"line-color": "#344054", "target-arrow-color": "#344054"}},
{selector: "edge[strength = 'weak']", style: {"line-style": "dotted"}},
{selector: ".display-blur", style: {"opacity": .24, "label": ""}},
{selector: ".display-blur:selected, .display-blur.hover", style: {"opacity": .78, "label": "data(label)"}},
{selector: ":selected", style: {"border-width": 4, "border-color": "#111827", "line-color": "#111827", "target-arrow-color": "#111827"}}
]
});
cy.on("tap", "node, edge", (event) => showDetails(event.target));
cy.on("tap", (event) => { if (event.target === cy) showDetails(null); });
cy.on("mouseover", "node, edge", (event) => {
event.target.addClass("hover");
const data = event.target.data();
popup.innerHTML = `<strong>${escapeHtml(data.name || data.label || data.id)}</strong><span class="meta">${escapeHtml(data.kind)} / ${escapeHtml(data.layer)}</span>`;
popup.style.left = `${Math.min(event.renderedPosition.x + 14, canvas.clientWidth - 270)}px`;
popup.style.top = `${Math.max(10, event.renderedPosition.y + 14)}px`;
popup.style.display = "block";
});
cy.on("mouseout", "node, edge", (event) => {
event.target.removeClass("hover");
popup.style.display = "none";
});
applyFilters();
};
[searchInput, layerFilter, reviewFilter, unresolvedFilter].forEach((control) => {
control.addEventListener("input", () => { focusSet = null; applyFilters(); });
});
document.querySelector("[data-action='fit']").addEventListener("click", () => cy && cy.fit(cy.elements(":visible"), 48));
document.querySelector("[data-action='focus']").addEventListener("click", applyFocus);
document.querySelector("[data-action='clear']").addEventListener("click", () => {
searchInput.value = "";
layerFilter.value = "";
reviewFilter.value = "";
unresolvedFilter.value = "";
focusSet = null;
applyFilters();
});
document.querySelector("[data-action='reset-overrides']").addEventListener("click", () => {
manualOverrides = {};
applyFilters();
});
document.querySelectorAll("[data-override]").forEach((button) => {
button.addEventListener("click", () => {
if (!selected) return;
manualOverrides[selected.id()] = button.dataset.override;
applyFilters();
});
});
if (!window.cytoscape) {
canvas.innerHTML = "<p class='notice'>Cytoscape.js could not be loaded.</p>";
return;
}
boot().catch((error) => {
canvas.innerHTML = `<p class='notice'>${escapeHtml(error.message)}</p>`;
});
})();
</script>
</body>
</html>
"""

View File

@@ -3,12 +3,15 @@ from __future__ import annotations
import argparse
import json
import sys
from dataclasses import dataclass
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
from .graph_explorer import fabric_graph_explorer_manifest, fabric_graph_explorer_payload
from .graph_explorer_ui import graph_explorer_page
from .registry import (
RegistryError,
RegistryStore,
@@ -23,6 +26,11 @@ from .registry import (
)
@dataclass(frozen=True)
class HtmlResponse:
body: str
class RegistryHandler(BaseHTTPRequestHandler):
store: RegistryStore
@@ -39,6 +47,8 @@ class RegistryHandler(BaseHTTPRequestHandler):
parts = _parts(path)
if path == "/health":
return HTTPStatus.OK, {"status": "ok"}
if parts == ["ui", "graph-explorer"]:
return HTTPStatus.OK, HtmlResponse(graph_explorer_page())
if path == "/status":
return HTTPStatus.OK, self.store.status()
if parts == ["repositories"]:
@@ -79,6 +89,14 @@ class RegistryHandler(BaseHTTPRequestHandler):
return HTTPStatus.OK, backstage_projection(self.store.combined_graph())
if parts == ["exports", "xregistry"]:
return HTTPStatus.OK, xregistry_projection(self.store.combined_graph())
if parts == ["exports", "graph-explorer"]:
return HTTPStatus.OK, fabric_graph_explorer_payload(
self.store.combined_graph(),
self.store.list_repositories(),
{str(snapshot["repo_slug"]) for snapshot in self.store.latest_snapshots()},
)
if parts == ["exports", "graph-explorer", "manifest"]:
return HTTPStatus.OK, fabric_graph_explorer_manifest()
if parts == ["artifacts"]:
return HTTPStatus.OK, self.store.list_artifacts(
repo_slug=_query_optional(query, "repo_slug"),
@@ -120,7 +138,10 @@ class RegistryHandler(BaseHTTPRequestHandler):
query = parse_qs(parsed.query)
try:
status, body = action(parsed.path, query)
self._send_json(int(status), body)
if isinstance(body, HtmlResponse):
self._send_text(int(status), body.body, "text/html; charset=utf-8")
else:
self._send_json(int(status), body)
except RegistryError as exc:
self._send_json(exc.status_code, {"error": exc.message})
except json.JSONDecodeError as exc:
@@ -140,8 +161,14 @@ class RegistryHandler(BaseHTTPRequestHandler):
def _send_json(self, status: int, body: Any) -> None:
payload = json.dumps(body, indent=2, sort_keys=True).encode("utf-8")
self._send_bytes(status, payload, "application/json; charset=utf-8")
def _send_text(self, status: int, body: str, content_type: str) -> None:
self._send_bytes(status, body.encode("utf-8"), content_type)
def _send_bytes(self, status: int, payload: bytes, content_type: str) -> None:
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)

View File

@@ -0,0 +1,232 @@
$schema: "https://json-schema.org/draft/2020-12/schema"
$id: "https://railiance.local/fabric/schemas/graph-explorer-manifest.schema.yaml"
title: "GraphExplorerManifest"
type: object
additionalProperties: false
required:
- apiVersion
- kind
- id
- title
- endpoints
- identity
- layers
- filter
- modes
- profile_persistence
properties:
apiVersion:
$ref: "./common.schema.yaml#/$defs/apiVersion"
kind:
type: string
const: GraphExplorerManifest
id:
type: string
minLength: 3
title:
type: string
minLength: 1
description:
type: string
engine:
type: object
additionalProperties: false
properties:
suggested_package:
type: string
preferred_library:
type: string
display_state_owner:
type: string
enum:
- host
- engine
- host-or-engine
endpoints:
type: object
additionalProperties: false
required:
- graph
properties:
graph:
$ref: "#/$defs/endpoint"
manifest:
$ref: "#/$defs/endpoint"
refilter:
$ref: "#/$defs/endpoint"
profiles:
type: object
additionalProperties: false
properties:
list:
$ref: "#/$defs/endpoint"
create:
$ref: "#/$defs/endpoint"
get:
$ref: "#/$defs/endpoint"
update:
$ref: "#/$defs/endpoint"
delete:
$ref: "#/$defs/endpoint"
duplicate:
$ref: "#/$defs/endpoint"
latest_default:
type: boolean
identity:
type: object
additionalProperties: false
required:
- node_id_field
- stable_key_field
- edge_id_field
- source_field
- target_field
properties:
node_id_field:
type: string
stable_key_field:
type: string
edge_id_field:
type: string
source_field:
type: string
target_field:
type: string
layers:
type: array
minItems: 1
items:
type: object
additionalProperties: false
required:
- id
- label
- order
properties:
id:
type: string
minLength: 1
label:
type: string
minLength: 1
order:
type: integer
minimum: 0
color:
type: string
pattern: "^#[0-9a-fA-F]{6}$"
grouping_fields:
$ref: "#/$defs/stringList"
search_fields:
$ref: "#/$defs/stringList"
filter:
type: object
additionalProperties: false
required:
- actions
- fields
properties:
actions:
type: array
minItems: 1
uniqueItems: true
items:
type: string
enum:
- show
- blur
- hide
fields:
type: array
items:
type: object
additionalProperties: false
required:
- id
- label
- type
properties:
id:
type: string
minLength: 1
label:
type: string
minLength: 1
type:
type: string
enum:
- string
- number
- boolean
- array
visual_encodings:
type: object
additionalProperties:
type: string
detail:
type: object
additionalProperties: false
properties:
node_fields:
$ref: "#/$defs/stringList"
edge_fields:
$ref: "#/$defs/stringList"
modes:
type: array
minItems: 1
items:
type: object
additionalProperties: false
required:
- id
- label
properties:
id:
type: string
minLength: 1
label:
type: string
minLength: 1
description:
type: string
requires_selection:
type: boolean
profile_persistence:
type: string
enum:
- none
- host
shareable_state:
type: object
additionalProperties: false
properties:
url_parameters:
type: boolean
profile_id:
type: boolean
state_blob:
type: boolean
$defs:
endpoint:
type: object
additionalProperties: false
required:
- method
- url
properties:
method:
type: string
enum:
- GET
- POST
- PATCH
- DELETE
url:
type: string
minLength: 1
stringList:
type: array
items:
type: string
minLength: 1

View File

@@ -0,0 +1,258 @@
$schema: "https://json-schema.org/draft/2020-12/schema"
$id: "https://railiance.local/fabric/schemas/graph-explorer-payload.schema.yaml"
title: "GraphExplorerPayload"
type: object
additionalProperties: false
required:
- apiVersion
- kind
- elements
- hidden_elements
properties:
apiVersion:
$ref: "./common.schema.yaml#/$defs/apiVersion"
kind:
type: string
const: GraphExplorerPayload
manifest_id:
type: string
generated_at:
type: string
format: date-time
repository:
type: object
additionalProperties: true
scope:
type: object
additionalProperties: true
mode:
type: string
profile:
anyOf:
- type: "null"
- $ref: "#/$defs/profile"
metrics:
type: object
additionalProperties:
anyOf:
- type: integer
- type: number
- type: boolean
- type: string
- type: "null"
filter:
type: object
additionalProperties: false
properties:
rules:
type: array
items:
$ref: "#/$defs/rule"
manual_overrides:
type: object
additionalProperties:
$ref: "#/$defs/displayState"
orphaned_overrides:
type: array
items:
type: string
precedence:
type: string
connected_edge_behavior:
type: string
elements:
type: array
items:
$ref: "#/$defs/element"
hidden_elements:
type: array
items:
$ref: "#/$defs/element"
impacts:
type: array
items:
type: object
additionalProperties: true
changed_fact_keys:
type: array
items:
type: string
$defs:
element:
type: object
additionalProperties: false
required:
- data
properties:
data:
type: object
additionalProperties: true
required:
- id
- stableKey
- kind
- layer
- displayState
properties:
id:
$ref: "#/$defs/stableKey"
key:
$ref: "#/$defs/stableKey"
stableKey:
$ref: "#/$defs/stableKey"
kind:
type: string
minLength: 1
layer:
type: string
minLength: 1
label:
type: string
name:
type: string
description:
type: string
repo:
type: string
domain:
type: string
lifecycle:
type: string
reviewState:
type: string
freshnessState:
type: string
displayState:
$ref: "#/$defs/displayState"
visibilitySource:
type: string
visibilityReason:
type: string
confidence:
anyOf:
- type: number
minimum: 0
maximum: 1
- type: "null"
visualSize:
type: number
minimum: 1
ownership:
type: string
unresolved:
type: boolean
source:
$ref: "#/$defs/stableKey"
target:
$ref: "#/$defs/stableKey"
sourceLayer:
type: string
targetLayer:
type: string
edgeType:
type: string
dependencyType:
type: string
strength:
type: string
enum:
- weak
- medium
- strong
edgeWidth:
type: number
minimum: 0
sameLayer:
type: boolean
connectedToBlurred:
type: boolean
path:
type: string
value:
type: string
attributes:
anyOf:
- type: object
additionalProperties: true
- type: array
items:
type: string
metadata:
type: object
additionalProperties: true
sourceReferences:
type: array
items:
type: object
additionalProperties:
anyOf:
- type: string
- type: integer
- type: number
- type: "null"
deepLinks:
type: object
additionalProperties:
type: string
classes:
type: string
group:
type: string
enum:
- nodes
- edges
displayState:
type: string
enum:
- show
- blur
- hide
profile:
type: object
additionalProperties: true
required:
- name
properties:
id:
anyOf:
- type: string
- type: integer
repository_id:
anyOf:
- type: string
- type: integer
name:
type: string
description:
type: string
default_mode:
type: string
filter_rules:
type: array
items:
$ref: "#/$defs/rule"
manual_overrides:
type: object
additionalProperties:
$ref: "#/$defs/displayState"
rule:
type: object
additionalProperties: true
required:
- action
properties:
action:
$ref: "#/$defs/displayState"
name:
type: string
description:
type: string
match:
type: object
additionalProperties: true
stableKey:
type: string
minLength: 1
maxLength: 240
pattern: "^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$"

View File

@@ -0,0 +1,201 @@
from __future__ import annotations
import json
import threading
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
from railiance_fabric.cli import main as cli_main
from railiance_fabric.graph import build_graph
from railiance_fabric.graph_explorer import (
fabric_graph_explorer_manifest,
fabric_graph_explorer_payload,
)
from railiance_fabric.registry import RegistryStore
from railiance_fabric.schema_validation import draft202012_validator
from railiance_fabric.server import RegistryHandler
def test_graph_explorer_manifest_and_payload_validate() -> None:
graph = build_graph([Path(".")]).to_export()
manifest = fabric_graph_explorer_manifest()
payload = fabric_graph_explorer_payload(
graph,
[
{
"slug": "railiance-fabric",
"name": "Railiance Fabric",
"state_hub_repo_id": "2c0de614-e468-4eb6-8157-470649ac8c05",
},
{
"slug": "registered-only",
"name": "Registered Only",
},
],
{"railiance-fabric"},
)
_validate_schema("graph-explorer-manifest.schema.yaml", manifest)
_validate_schema("graph-explorer-payload.schema.yaml", payload)
nodes = [element for element in payload["elements"] if "source" not in element["data"]]
edges = [element for element in payload["elements"] if "source" in element["data"]]
registered_only = next(
element for element in nodes if element["data"]["id"] == "repo:registered-only"
)
assert registered_only["data"]["reviewState"] == "candidate"
assert registered_only["data"]["unresolved"] is True
assert any(edge["data"]["edgeType"] == "declares" for edge in edges)
assert any(node["data"]["sourceReferences"] for node in nodes if node["data"]["kind"] != "Repository")
assert payload["metrics"]["registered_repo_count"] == 2
assert payload["metrics"]["unresolved_count"] == 0
def test_cli_exports_graph_explorer_payload(capsys) -> None:
assert cli_main(["export", "--format", "graph-explorer"]) == 0
payload = json.loads(capsys.readouterr().out)
_validate_schema("graph-explorer-payload.schema.yaml", payload)
assert payload["kind"] == "GraphExplorerPayload"
assert any(
element["data"].get("sourceReferences")
for element in payload["elements"]
if "source" not in element["data"]
)
def test_graph_explorer_payload_accepts_repo_scoping_shape() -> None:
payload = {
"apiVersion": "railiance.fabric/v1alpha1",
"kind": "GraphExplorerPayload",
"manifest_id": "repo-scoping.dependency-graph",
"mode": "full",
"profile": {
"id": 1,
"repository_id": 1,
"name": "Evidence Audit",
"description": "Show supporting evidence.",
"default_mode": "full",
"filter_rules": [
{"name": "Blur facts", "action": "blur", "match": {"layer": "fact"}}
],
"manual_overrides": {"feature:1": "show"},
},
"filter": {
"rules": [],
"manual_overrides": {},
"orphaned_overrides": [],
},
"elements": [
{
"data": {
"id": "fact:document:README.md",
"stableKey": "fact:document:README.md",
"kind": "fact",
"layer": "fact",
"label": "README.md",
"displayState": "blur",
"reviewState": "accepted",
"freshnessState": "current",
"confidence": 0.8,
"visualSize": 50,
"sourceReferences": [{"path": "README.md", "kind": "document"}],
},
"classes": "fact display-blur",
},
{
"data": {
"id": "capability:1",
"stableKey": "capability:1",
"kind": "capability",
"layer": "capability",
"label": "Registry Capabilities",
"displayState": "show",
"reviewState": "accepted",
},
},
{
"data": {
"id": "edge:fact:capability",
"stableKey": "edge:fact:capability",
"kind": "edge",
"layer": "dependency",
"label": "supports",
"source": "fact:document:README.md",
"target": "capability:1",
"edgeType": "supports",
"dependencyType": "supports",
"strength": "strong",
"edgeWidth": 5,
"sameLayer": False,
"displayState": "show",
},
"classes": "supports strong",
},
],
"hidden_elements": [],
}
_validate_schema("graph-explorer-payload.schema.yaml", payload)
def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
store = RegistryStore(tmp_path / "registry.sqlite3")
store.init_schema()
store.upsert_repository(
{
"slug": "railiance-fabric",
"name": "Railiance Fabric",
"state_hub_repo_id": "2c0de614-e468-4eb6-8157-470649ac8c05",
}
)
store.upsert_repository({"slug": "registered-only", "name": "Registered Only"})
store.add_snapshot(
"railiance-fabric",
{
"commit": "test-commit",
"generated_at": "2026-05-18T00:00:00Z",
"graph": build_graph([Path(".")]).to_export(),
},
)
class Handler(RegistryHandler):
pass
Handler.store = store
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
base_url = f"http://127.0.0.1:{server.server_port}"
with urllib.request.urlopen(
f"{base_url}/exports/graph-explorer/manifest",
timeout=5,
) as response:
manifest = json.loads(response.read())
with urllib.request.urlopen(f"{base_url}/exports/graph-explorer", timeout=5) as response:
payload = json.loads(response.read())
with urllib.request.urlopen(f"{base_url}/ui/graph-explorer", timeout=5) as response:
page = response.read().decode("utf-8")
content_type = response.headers["Content-Type"]
_validate_schema("graph-explorer-manifest.schema.yaml", manifest)
_validate_schema("graph-explorer-payload.schema.yaml", payload)
assert manifest["id"] == "railiance-fabric.registry-map"
assert payload["metrics"]["registered_only_repo_count"] == 1
assert content_type.startswith("text/html")
assert 'id="graph-canvas"' in page
assert "cytoscape.min.js" in page
assert "/exports/graph-explorer/manifest" in page
assert 'data-override="hide"' in page
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
def _validate_schema(schema_name: str, payload: dict[str, object]) -> None:
validator = draft202012_validator(Path("schemas") / schema_name)
validator.validate(payload)

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Interactive Fabric Map"
domain: railiance
repo: railiance-fabric
status: proposed
status: active
owner: codex
topic_slug: railiance
planning_priority: high
@@ -19,127 +19,212 @@ updated: "2026-05-18"
## Goal
Build a clever interactive auto-layouting map over the registered Fabric
entities so humans and agents can orient inside the infrastructure graph.
entities so humans and agents can orient inside the infrastructure graph, while
keeping the visualization layer reusable enough to serve other graph-producing
repos.
The current repo has static graph exports through JSON and Mermaid. That is
useful for snapshots and docs, but it does not yet provide a navigable
orientation surface for the registered ecosystem.
Repo-scoping has already implemented the first serious local graph explorer
shape through RREG-WP-0010 and RREG-WP-0011. Fabric should reuse those lessons
instead of building a parallel one-off map.
Reference implementation inputs:
- `repo-scoping/docs/adr-dependency-graph-visualization-framework.md` selects
Cytoscape.js for graph-native pan, zoom, selection, styling, filtering, and
path exploration.
- `repo-scoping/docs/dependency-visualization-exploration.md` defines the
layered graph model, display states, filter rules, manual overrides, and view
profiles.
- `repo-scoping/src/repo_scoping/core/service.py` exposes Cytoscape-compatible
graph payloads with stable keys, metadata-rich nodes and edges, deterministic
show/blur/hide visibility evaluation, profile application, hidden element
recovery data, and visual sizing hints.
- `repo-scoping/src/repo_scoping/web_ui/views.py` provides the current
interactive shell: profile controls, filter controls, manual overrides,
focus-by-depth, hover popups, selection details, and a layered preset layout.
- `repo-scoping/tests/test_web_api.py` verifies the graph endpoint, ad hoc
filters, profile CRUD and duplicate behavior, latest-profile defaulting, and
UI wiring.
## Design Direction
The first map should be an operational tool, not a landing page. It should open
directly into the graph and make the current infrastructure legible through
stable grouping, filtering, and drill-down.
The first Fabric map should be an operational tool, not a landing page. It
should open directly into the graph and make the current infrastructure legible
through stable grouping, filtering, drill-down, and saved perspectives.
The map should support:
- force or layered auto-layout with stable seeded positions
- force, cose, concentric, or layered auto-layout with stable seeded positions
- grouping by domain, repo, service, capability, interface, dependency, and
unresolved status
- search across repo slugs, graph ids, names, capabilities, interfaces, and
library names
- quick filters for active services, external interfaces, missing declarations,
unresolved dependencies, blast radius, and dependency path
- quick filters for review state, active services, external interfaces, missing
declarations, unresolved dependencies, blast radius, and dependency path
- display states of `show`, `blur`, and `hide`, with deterministic rule
precedence and manual overrides
- hover and selection detail panels with links back to registry endpoints,
State Hub repo ids, workplans, and source files where available
- shareable map state through URL query parameters or a copied state blob
- saved view profiles with filter rules, manual overrides, timestamps,
duplicate/delete behavior, latest-profile defaulting, URL `profile_id`
loading, and orphaned override reporting
- shareable map state through URL query parameters, profile ids, or a copied
state blob
- graceful handling of registered-only repos that do not yet have local
`fabric/` declarations
The reusable engine should be manifest-driven. Fabric and repo-scoping should be
able to wire the same interaction shell to different graph-producing services by
providing a manifest that describes:
- graph and profile endpoint URLs
- node and edge identity fields
- layer ordering and grouping fields
- visual encodings for kind, layer, review state, lifecycle, confidence,
strength, freshness, and unresolved status
- filterable fields and supported rule actions
- detail-panel fields and deep links
- available modes such as full graph, impact, selected path, neighborhood focus,
and onboarding gaps
- profile persistence capabilities for each host application
## Architecture Notes
Keep the registry as the data source. The UI should consume registry exports and
queries rather than reloading repo files directly.
Likely implementation path:
Refined implementation path:
- add a map-oriented projection endpoint or CLI export that preserves existing
graph ids while adding visual facets
- serve a small local web UI from the registry service or a separate lightweight
static bundle
- use a proven browser graph/layout library instead of hand-rolling physics
- keep layout state client-side first; persist only after the workflows prove
useful
- define a host-neutral graph manifest and payload contract that can represent
both repo-scoping dependency graphs and Fabric ecosystem graphs
- add a Fabric map projection endpoint or CLI export that preserves existing
graph ids while adding visual facets expected by the engine
- prototype a small local web UI as a reusable graph explorer shell, likely with
Cytoscape.js unless the transfer review finds a better fit for the
cross-repo contract
- wire Fabric through an adapter manifest rather than hard-coding Fabric fields
throughout the UI
- verify that a repo-scoping adapter could consume the same shell with minimal
glue, using RREG-WP-0010 and RREG-WP-0011 behavior as the parity target
- decide whether the graph explorer should be extracted into a separate
repository after the manifest contract and first two adapters stabilize
Candidate extraction shape:
- working name: `graph-explorer-engine`
- reusable package: static graph explorer assets plus TypeScript or JSON schema
definitions for manifests, payloads, rules, profiles, and visual facets
- host adapters: Fabric registry adapter and repo-scoping dependency graph
adapter
- host responsibilities: authentication, storage, graph projection, profile
persistence, and deep-link targets
- engine responsibilities: layout, filtering, display-state evaluation if not
provided by host, hover and selection UI, profile control surface, focus
modes, and shareable state
## Tasks
### T01 - Map UX And Information Architecture
### T01 - Repo-Scoping Transfer Review
```task
id: RAIL-FAB-WP-0008-T01
status: todo
status: done
priority: high
state_hub_task_id: "9844a9a7-f285-4523-a8d6-4ca62008ce08"
```
Define map views, entity visual grammar, navigation behaviors, and orientation
affordances.
Review the repo-scoping graph implementation and turn RREG-WP-0010/RREG-WP-0011
into concrete Fabric requirements and reusable-engine requirements.
Acceptance notes:
- Decide initial visual grammar for repos, services, capabilities, interfaces,
dependencies, bindings, libraries, and registered-only repos.
- Decide default grouping and default filters for first load.
- Decide whether the first implementation uses a force-directed, layered, or
hybrid layout.
- Identify which repo-scoping behaviors should be reused directly: Cytoscape
payload shape, layered layout, show/blur/hide display states, filter rules,
manual overrides, view profiles, hover popups, focus depth, latest profile
defaulting, review-state filtering, confidence sizing, and strength sizing.
- Identify repo-scoping-specific pieces that should remain adapter logic:
fact/evidence/feature/capability/ability/scope layers, impact analysis, and
candidate-vs-accepted semantics.
- Decide the first Fabric visual grammar for repos, services, capabilities,
interfaces, dependencies, bindings, libraries, State Hub links, unresolved
gaps, and registered-only repos.
- Capture a short extraction recommendation before implementation starts.
### T02 - Layout-Ready Graph Projection
### T02 - Manifest And Adapter Contract
```task
id: RAIL-FAB-WP-0008-T02
status: todo
status: done
priority: high
state_hub_task_id: "cb0cc9f1-5225-47e5-8b47-a945c92e7168"
```
Add or shape registry exports for map-friendly nodes, edges, groups, facets, and
stable identifiers.
Define the reusable graph explorer manifest, payload, filtering, and profile
contract that lets multiple repos mount the same visualization shell.
Acceptance notes:
- Include registered repositories even when they have no graph snapshot yet.
- Include node type, domain, repo, lifecycle, unresolved flags, and edge type.
- Preserve stable ids so layout state and deep links do not churn.
- Define required and optional graph payload fields, including `id`,
`stableKey`, `kind`, `layer`, label, description, source references,
review/lifecycle state, confidence, freshness, unresolved state, ownership,
visual size, edge type, edge strength, same-layer hints, and deep links.
- Define a manifest schema for endpoints, layer order, grouping fields, style
tokens, detail fields, search fields, filterable fields, graph modes, profile
capabilities, and shareable state support.
- Decide whether display-state evaluation lives in the host service, the engine,
or both with a clear precedence rule.
- Include compatibility notes for repo-scoping's existing graph API so an
adapter can be added without forcing a repo-scoping rewrite.
### T03 - Interactive Auto-Layout Map UI
### T03 - Fabric Map Projection
```task
id: RAIL-FAB-WP-0008-T03
status: todo
status: done
priority: high
state_hub_task_id: "ecd967fc-05ed-4cda-bca2-cf74e26e60b3"
```
Build a local interactive map with layout controls, filtering, search, zoom,
pan, and detail panels.
Add or shape Fabric registry exports for map-friendly nodes, edges, groups,
facets, unresolved gaps, and stable identifiers.
Acceptance notes:
- Include registered repositories even when they have no graph snapshot yet.
- Include node type, domain, repo, lifecycle, State Hub ids, unresolved flags,
declaration source links, and edge type.
- Preserve stable ids so layout state, profiles, and deep links do not churn.
- Export a manifest-backed graph payload that can be consumed by the reusable
shell without Fabric-specific UI branches.
### T04 - Interactive Graph Explorer Shell
```task
id: RAIL-FAB-WP-0008-T04
status: in_progress
priority: high
state_hub_task_id: "75c1f234-026c-44ed-9c88-db39653b81e0"
```
Build the manifest-driven interactive map shell with layout controls, filtering,
search, zoom, pan, hover popups, selection details, focus modes, and profile
controls.
Acceptance notes:
- The first screen is the actual map.
- Users can search, select, expand/collapse groups, and inspect details without
leaving the page.
- Users can search, select, filter, blur, hide, focus by depth, inspect details,
and recover hidden or over-filtered elements without leaving the page.
- The shell supports profile create/update/duplicate/delete where the host
manifest declares persistence support.
- The map remains useful with the current sparse state where many repos are
registered-only.
### T04 - Operational Orientation Workflows
```task
id: RAIL-FAB-WP-0008-T04
status: todo
priority: medium
state_hub_task_id: "75c1f234-026c-44ed-9c88-db39653b81e0"
```
Add workflows for blast-radius exploration, dependency paths, unresolved gaps,
repo grouping, and current selection sharing.
Acceptance notes:
- Selecting an interface can show consumers and blast radius.
- Selecting a service can show its dependency path and provider chain.
- Registered-only repos are visible as onboarding gaps rather than noise.
### T05 - Verification And Documentation
### T05 - Fabric Orientation Workflows
```task
id: RAIL-FAB-WP-0008-T05
@@ -148,12 +233,37 @@ priority: medium
state_hub_task_id: "64fe53f1-fbea-4624-8f52-1b5e2a27cf67"
```
Verify the map against seeded registry data, document usage, and capture
follow-on data quality gaps.
Add Fabric-specific workflows for blast-radius exploration, dependency paths,
unresolved gaps, repo grouping, onboarding gaps, and shareable context.
Acceptance notes:
- Selecting an interface can show consumers and blast radius.
- Selecting a service can show its dependency path and provider chain.
- Registered-only repos are visible as onboarding gaps rather than noise.
- Saved views can separate current operational context from backlog or
onboarding cleanup.
### T06 - Extraction Decision, Verification, And Documentation
```task
id: RAIL-FAB-WP-0008-T06
status: todo
priority: medium
state_hub_task_id: "d5567337-efe7-4fe6-8379-9e5505e25f6f"
```
Decide whether to extract the graph explorer into a separate repository, verify
the Fabric implementation against seeded registry data, and document launch,
refresh, manifest, and adapter usage.
Acceptance notes:
- Verify against the local registry after `registry/local-repos.yaml` onboarding.
- Include at least one automated projection/API test and one UI smoke test.
- Include an adapter parity checklist for repo-scoping against
RREG-WP-0010/RREG-WP-0011 behavior.
- If extraction is recommended, propose the new repo name, source boundaries,
package/public API, manifest schema ownership, and migration steps.
- Document how to launch the map and how to refresh the underlying registry
data.