diff --git a/railiance_fabric/canon.py b/railiance_fabric/canon.py index 41a315f..69237ae 100644 --- a/railiance_fabric/canon.py +++ b/railiance_fabric/canon.py @@ -91,24 +91,34 @@ NODE_KIND_CANON_MAP: dict[str, CanonNodeMapping] = { "DomainName": CanonNodeMapping("endpoint", "model/network", "partial"), "ExternalLibrary": CanonNodeMapping("software-system", "model/landscape", "partial"), "FabricRegistryEntry": CanonNodeMapping("source-repository", "model/devsecops", "partial"), + "Fabric": CanonNodeMapping("control", "model/governance", "gap"), + "FabricActor": CanonNodeMapping("control", "model/governance", "gap"), "InterfaceDeclaration": CanonNodeMapping("endpoint", "model/network", "partial"), "Library": CanonNodeMapping("software-system", "model/landscape", "partial"), "Lockfile": CanonNodeMapping("evidence", "model/observability", "partial"), + "Netkingdom": CanonNodeMapping("software-system", "model/landscape", "gap"), "NetworkPort": CanonNodeMapping("endpoint", "model/network", "direct"), + "ProfitCenter": CanonNodeMapping("control", "model/governance", "gap"), "Repository": CanonNodeMapping("source-repository", "model/devsecops", "direct"), "RuntimeService": CanonNodeMapping("runtime-resource", "model/landscape", "direct"), "ScoreWorkload": CanonNodeMapping("deployment", "model/devsecops", "direct"), "Server": CanonNodeMapping("runtime-resource", "model/landscape", "partial"), "ServiceConfig": CanonNodeMapping("evidence", "model/observability", "partial"), "ServiceDeclaration": CanonNodeMapping("service", "model/landscape", "direct"), + "Subfabric": CanonNodeMapping("control", "model/governance", "gap"), + "UtilityInterface": CanonNodeMapping("endpoint", "model/network", "partial"), + "CostCenter": CanonNodeMapping("control", "model/governance", "gap"), } EDGE_TYPE_CANON_MAP: dict[str, CanonEdgeMapping] = { "available_via": CanonEdgeMapping("exposes", "model/network", "partial"), + "attributed_to_cost_center": CanonEdgeMapping("governed_by", "model/governance", "gap"), + "attributed_to_profit_center": CanonEdgeMapping("governed_by", "model/governance", "gap"), "binds": CanonEdgeMapping("depends_on", "model/landscape", "partial"), "builds_container": CanonEdgeMapping("built_from", "model/devsecops", "partial"), "cataloged_as": CanonEdgeMapping("evidenced_by", "model/observability", "partial"), "consumes": CanonEdgeMapping("depends_on", "model/landscape", "partial"), + "contains": CanonEdgeMapping("part_of", "model/landscape", "partial"), "declares": CanonEdgeMapping("part_of", "model/devsecops", "partial", display_only=True), "declares_package": CanonEdgeMapping("built_from", "model/devsecops", "partial"), "defines_deployment": CanonEdgeMapping("built_from", "model/devsecops", "partial"), @@ -122,8 +132,11 @@ EDGE_TYPE_CANON_MAP: dict[str, CanonEdgeMapping] = { "listens_on": CanonEdgeMapping("exposes", "model/network", "direct"), "names_endpoint": CanonEdgeMapping("exposes", "model/network", "partial"), "opens_port": CanonEdgeMapping("exposes", "model/network", "partial"), + "operated_by": CanonEdgeMapping("governed_by", "model/governance", "partial"), + "owned_by": CanonEdgeMapping("governed_by", "model/governance", "partial"), "owns_deployment": CanonEdgeMapping("part_of", "model/devsecops", "partial", display_only=True), "provides": CanonEdgeMapping("implements", "model/landscape", "partial"), + "provides_utility_to": CanonEdgeMapping("depends_on", "model/landscape", "partial"), "resolves_to": CanonEdgeMapping("flows_to", "model/network", "partial"), "routes_to_port": CanonEdgeMapping("flows_to", "model/network", "partial"), "routes_to_service": CanonEdgeMapping("flows_to", "model/network", "partial"), diff --git a/railiance_fabric/financial.py b/railiance_fabric/financial.py new file mode 100644 index 0000000..5f08b16 --- /dev/null +++ b/railiance_fabric/financial.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +import json +from typing import Any + +FINANCIAL_API_VERSION = "railiance.fabric/v1alpha2" +FINANCIAL_SCHEMA_VERSION = "financial-fabric-v1" + +ACTOR_ROLES = {"king", "lord", "tenant", "operator", "steward"} +OWNERSHIP_RESOLUTIONS = {"explicit", "inherited", "unresolved", "ambiguous"} +EVIDENCE_STATES = {"observed", "declared", "inferred", "proposed", "gap"} +REVIEW_STATES = {"accepted", "candidate", "needs_review", "rejected"} +RELATIONSHIP_CATEGORIES = { + "containment", + "ownership", + "technical", + "utility", + "accounting", + "evidence", +} +FABRIC_KINDS = {"Fabric", "Subfabric"} +ENVIRONMENT_LABELS = {"local", "dev", "staging", "prod", "lab", "all"} + + +def is_financial_graph_export(graph: dict[str, Any]) -> bool: + return ( + graph.get("apiVersion") == FINANCIAL_API_VERSION + or graph.get("schema_version") == FINANCIAL_SCHEMA_VERSION + ) + + +def materialize_financial_graph_export(graph: dict[str, Any]) -> dict[str, Any]: + result = json.loads(json.dumps(graph)) + if not is_financial_graph_export(result): + return result + + result["apiVersion"] = FINANCIAL_API_VERSION + result.setdefault("kind", "FabricGraphExport") + result.setdefault("schema_version", FINANCIAL_SCHEMA_VERSION) + result.setdefault("compatibility", {}) + result.setdefault("actors", []) + result.setdefault("fabrics", []) + result.setdefault("nodes", []) + result.setdefault("edges", []) + result.setdefault("unresolved", []) + + for node in _items(result, "nodes"): + _materialize_node(node) + for edge in _items(result, "edges"): + _materialize_edge(edge) + return result + + +def financial_graph_errors(graph: dict[str, Any]) -> list[str]: + if not is_financial_graph_export(graph): + return [] + + errors: list[str] = [] + if graph.get("apiVersion") != FINANCIAL_API_VERSION: + errors.append(f"apiVersion must be {FINANCIAL_API_VERSION!r}") + if graph.get("kind") != "FabricGraphExport": + errors.append("kind must be 'FabricGraphExport'") + if graph.get("schema_version") != FINANCIAL_SCHEMA_VERSION: + errors.append(f"schema_version must be {FINANCIAL_SCHEMA_VERSION!r}") + + netkingdom = graph.get("netkingdom") + if not isinstance(netkingdom, dict): + errors.append("netkingdom must be an object") + netkingdom = {} + netkingdom_id = _required_text(errors, "netkingdom.id", netkingdom, "id") + king_actor_id = _required_text(errors, "netkingdom.king_actor_id", netkingdom, "king_actor_id") + + actors = _indexed_items(errors, graph, "actors") + actor_roles: dict[str, str] = {} + for index, actor in actors: + actor_id = _required_text(errors, f"actors[{index}].id", actor, "id") + role = _required_text(errors, f"actors[{index}].role", actor, "role") + if role and role not in ACTOR_ROLES: + errors.append(f"actors[{index}].role {role!r} is not valid") + if actor_id: + if actor_id in actor_roles: + errors.append(f"actors[{index}].id {actor_id!r} is duplicated") + actor_roles[actor_id] = role + if king_actor_id and actor_roles.get(king_actor_id) != "king": + errors.append("netkingdom.king_actor_id must reference an actor with role 'king'") + + fabrics = _indexed_items(errors, graph, "fabrics") + fabric_kinds: dict[str, str] = {} + for index, fabric in fabrics: + fabric_id = _required_text(errors, f"fabrics[{index}].id", fabric, "id") + kind = _required_text(errors, f"fabrics[{index}].kind", fabric, "kind") + if kind and kind not in FABRIC_KINDS: + errors.append(f"fabrics[{index}].kind {kind!r} is not valid") + if fabric_id: + if fabric_id in ENVIRONMENT_LABELS: + errors.append(f"fabrics[{index}].id must not be an environment label") + if fabric_id in fabric_kinds: + errors.append(f"fabrics[{index}].id {fabric_id!r} is duplicated") + fabric_kinds[fabric_id] = kind + if _text(fabric.get("netkingdom_id")) != netkingdom_id: + errors.append(f"fabrics[{index}].netkingdom_id must match netkingdom.id") + if kind == "Fabric": + lord_actor_id = _required_text(errors, f"fabrics[{index}].lord_actor_id", fabric, "lord_actor_id") + if lord_actor_id and actor_roles.get(lord_actor_id) not in {"lord", "king"}: + errors.append(f"fabrics[{index}].lord_actor_id must reference a lord or king actor") + if kind == "Subfabric": + parent_id = _required_text(errors, f"fabrics[{index}].parent_fabric_id", fabric, "parent_fabric_id") + tenant_actor_id = _required_text(errors, f"fabrics[{index}].tenant_actor_id", fabric, "tenant_actor_id") + if parent_id and parent_id not in fabric_kinds: + errors.append(f"fabrics[{index}].parent_fabric_id references unknown fabric {parent_id!r}") + if tenant_actor_id and actor_roles.get(tenant_actor_id) != "tenant": + errors.append(f"fabrics[{index}].tenant_actor_id must reference a tenant actor") + + node_ids = set() + nodes = _indexed_items(errors, graph, "nodes") + for index, node in nodes: + node_id = _required_text(errors, f"nodes[{index}].id", node, "id") + _required_text(errors, f"nodes[{index}].kind", node, "kind") + _required_text(errors, f"nodes[{index}].name", node, "name") + if node_id: + if node_id in node_ids: + errors.append(f"nodes[{index}].id {node_id!r} is duplicated") + node_ids.add(node_id) + review_state = _validate_evidence(errors, f"nodes[{index}]", node) + _validate_containment(errors, f"nodes[{index}]", node, netkingdom_id, fabric_kinds, accepted=review_state == "accepted") + _validate_ownership(errors, f"nodes[{index}]", node, actor_roles, accepted=review_state == "accepted") + _validate_optional_object(errors, f"nodes[{index}].accounting", node, "accounting") + + edges = _indexed_items(errors, graph, "edges") + for index, edge in edges: + path = f"edges[{index}]" + source = _required_text(errors, f"{path}.from", edge, "from") + target = _required_text(errors, f"{path}.to", edge, "to") + edge_type = _required_text(errors, f"{path}.type", edge, "type") + if source and source not in node_ids: + errors.append(f"{path}.from references unknown node {source!r}") + if target and target not in node_ids: + errors.append(f"{path}.to references unknown node {target!r}") + category = _required_text(errors, f"{path}.relationship_category", edge, "relationship_category") + if category and category not in RELATIONSHIP_CATEGORIES: + errors.append(f"{path}.relationship_category {category!r} is not valid") + _validate_evidence(errors, path, edge) + _validate_optional_object(errors, f"{path}.accounting", edge, "accounting") + if edge_type == "provides_utility_to" and category != "utility": + errors.append(f"{path}.relationship_category must be 'utility' for provides_utility_to edges") + if category == "utility": + _validate_utility_edge(errors, path, edge, actor_roles, fabric_kinds) + + for index, item in _indexed_items(errors, graph, "unresolved"): + _required_text(errors, f"unresolved[{index}].target_id", item, "target_id") + _required_text(errors, f"unresolved[{index}].kind", item, "kind") + _required_text(errors, f"unresolved[{index}].message", item, "message") + + return errors + + +def merge_financial_graph_exports(graphs: list[dict[str, Any]], *, generated_at: str) -> dict[str, Any]: + merged: dict[str, Any] = { + "apiVersion": FINANCIAL_API_VERSION, + "kind": "FabricGraphExport", + "schema_version": FINANCIAL_SCHEMA_VERSION, + "generated_at": generated_at, + "source": {"producer": "railiance-fabric", "registry": "registry", "commit": ""}, + "compatibility": {"legacy_v1alpha1_supported": True, "breaking_reset": False}, + "actors": [], + "fabrics": [], + "nodes": [], + "edges": [], + "unresolved": [], + } + actors: dict[str, dict[str, Any]] = {} + fabrics: dict[str, dict[str, Any]] = {} + nodes: dict[str, dict[str, Any]] = {} + edges: dict[str, dict[str, Any]] = {} + unresolved: dict[str, dict[str, Any]] = {} + + for graph in graphs: + graph = materialize_financial_graph_export(graph) + if "netkingdom" in graph and "netkingdom" not in merged: + merged["netkingdom"] = graph["netkingdom"] + for actor in _items(graph, "actors"): + actor_id = str(actor.get("id") or "") + if actor_id: + actors[actor_id] = actor + for fabric in _items(graph, "fabrics"): + fabric_id = str(fabric.get("id") or "") + if fabric_id: + fabrics[fabric_id] = fabric + for node in _items(graph, "nodes"): + node_id = str(node.get("id") or "") + if node_id: + nodes[node_id] = node + for edge in _items(graph, "edges"): + edges[_edge_identity(edge)] = edge + for item in _items(graph, "unresolved"): + target = str(item.get("target_id") or "") + kind = str(item.get("kind") or "") + if target or kind: + unresolved[f"{target}:{kind}"] = item + + merged["actors"] = [actors[key] for key in sorted(actors)] + merged["fabrics"] = [fabrics[key] for key in sorted(fabrics)] + merged["nodes"] = [nodes[key] for key in sorted(nodes)] + merged["edges"] = [edges[key] for key in sorted(edges)] + merged["unresolved"] = [unresolved[key] for key in sorted(unresolved)] + return materialize_financial_graph_export(merged) + + +def _materialize_node(node: dict[str, Any]) -> None: + attrs = node.get("attributes") if isinstance(node.get("attributes"), dict) else {} + for key in ("containment", "ownership", "accounting"): + if key not in node and isinstance(attrs.get(key), dict): + node[key] = attrs[key] + node.setdefault("evidence", _legacy_evidence(node, attrs)) + + +def _materialize_edge(edge: dict[str, Any]) -> None: + attrs = edge.get("attributes") if isinstance(edge.get("attributes"), dict) else {} + if "accounting" not in edge and isinstance(attrs.get("accounting"), dict): + edge["accounting"] = attrs["accounting"] + edge.setdefault("relationship_category", _relationship_category(edge)) + edge.setdefault("evidence", _legacy_evidence(edge, attrs)) + if edge.get("relationship_category") == "utility": + provider = edge.get("provider") if isinstance(edge.get("provider"), dict) else {} + consumer = edge.get("consumer") if isinstance(edge.get("consumer"), dict) else {} + boundary = edge.setdefault("boundary", {}) + if isinstance(boundary, dict) and provider and consumer: + boundary.setdefault("crosses_fabric_boundary", provider.get("fabric_id") != consumer.get("fabric_id")) + boundary.setdefault("crosses_subfabric_boundary", provider.get("subfabric_id") != consumer.get("subfabric_id")) + + +def _legacy_evidence(item: dict[str, Any], attrs: dict[str, Any]) -> dict[str, Any]: + confidence = attrs.get("discovery_confidence") + evidence: dict[str, Any] = { + "state": item.get("evidence_state") or "declared", + "review_state": attrs.get("discovery_review_state") or "accepted", + "refs": attrs.get("discovery_source_anchors") if isinstance(attrs.get("discovery_source_anchors"), list) else [], + } + if isinstance(confidence, (int, float)): + evidence["confidence"] = float(confidence) + return evidence + + +def _relationship_category(edge: dict[str, Any]) -> str: + edge_type = str(edge.get("type") or "") + if edge_type in {"contains", "part_of"}: + return "containment" + if edge_type in {"owned_by", "operated_by"}: + return "ownership" + if edge_type == "provides_utility_to": + return "utility" + if edge_type in {"attributed_to_cost_center", "attributed_to_profit_center"}: + return "accounting" + if edge_type == "evidenced_by": + return "evidence" + return "technical" + + +def _validate_evidence(errors: list[str], path: str, item: dict[str, Any]) -> str: + evidence = item.get("evidence") + if not isinstance(evidence, dict): + errors.append(f"{path}.evidence must be an object") + return "" + state = _required_text(errors, f"{path}.evidence.state", evidence, "state") + review_state = _required_text(errors, f"{path}.evidence.review_state", evidence, "review_state") + if state and state not in EVIDENCE_STATES: + errors.append(f"{path}.evidence.state {state!r} is not valid") + if review_state and review_state not in REVIEW_STATES: + errors.append(f"{path}.evidence.review_state {review_state!r} is not valid") + return review_state + + +def _validate_containment( + errors: list[str], + path: str, + node: dict[str, Any], + netkingdom_id: str, + fabric_kinds: dict[str, str], + *, + accepted: bool, +) -> None: + containment = node.get("containment") + if not isinstance(containment, dict): + if accepted: + errors.append(f"{path}.containment must be an object for accepted nodes") + return + if accepted: + if _text(containment.get("netkingdom_id")) != netkingdom_id: + errors.append(f"{path}.containment.netkingdom_id must match netkingdom.id") + fabric_id = _required_text(errors, f"{path}.containment.fabric_id", containment, "fabric_id") + if fabric_id: + if fabric_id in ENVIRONMENT_LABELS: + errors.append(f"{path}.containment.fabric_id must not be an environment label") + if fabric_id not in fabric_kinds: + errors.append(f"{path}.containment.fabric_id references unknown fabric {fabric_id!r}") + subfabric_id = _text(containment.get("subfabric_id")) + if subfabric_id: + if fabric_kinds.get(subfabric_id) != "Subfabric": + errors.append(f"{path}.containment.subfabric_id references unknown subfabric {subfabric_id!r}") + environment = _text(containment.get("environment")) + if environment and environment in fabric_kinds: + errors.append(f"{path}.containment.environment must not reference a fabric id") + + +def _validate_ownership( + errors: list[str], + path: str, + node: dict[str, Any], + actor_roles: dict[str, str], + *, + accepted: bool, +) -> None: + ownership = node.get("ownership") + if not isinstance(ownership, dict): + if accepted: + errors.append(f"{path}.ownership must be an object for accepted nodes") + return + owner_actor_id = _required_text(errors, f"{path}.ownership.owner_actor_id", ownership, "owner_actor_id") + owner_role = _required_text(errors, f"{path}.ownership.owner_role", ownership, "owner_role") + resolution = _required_text(errors, f"{path}.ownership.resolution", ownership, "resolution") + if owner_role and owner_role not in ACTOR_ROLES: + errors.append(f"{path}.ownership.owner_role {owner_role!r} is not valid") + if resolution and resolution not in OWNERSHIP_RESOLUTIONS: + errors.append(f"{path}.ownership.resolution {resolution!r} is not valid") + if owner_actor_id and owner_actor_id not in actor_roles: + errors.append(f"{path}.ownership.owner_actor_id references unknown actor {owner_actor_id!r}") + if owner_actor_id and owner_role and actor_roles.get(owner_actor_id) not in {owner_role, ""}: + errors.append(f"{path}.ownership.owner_role does not match referenced actor role") + if accepted and resolution in {"unresolved", "ambiguous"}: + errors.append(f"{path}.ownership.resolution must be explicit or inherited for accepted nodes") + + +def _validate_utility_edge( + errors: list[str], + path: str, + edge: dict[str, Any], + actor_roles: dict[str, str], + fabric_kinds: dict[str, str], +) -> None: + provider = _required_object(errors, f"{path}.provider", edge, "provider") + consumer = _required_object(errors, f"{path}.consumer", edge, "consumer") + boundary = _required_object(errors, f"{path}.boundary", edge, "boundary") + utility = _required_object(errors, f"{path}.utility", edge, "utility") + for side_name, side in (("provider", provider), ("consumer", consumer)): + actor_id = _required_text(errors, f"{path}.{side_name}.owner_actor_id", side, "owner_actor_id") + fabric_id = _required_text(errors, f"{path}.{side_name}.fabric_id", side, "fabric_id") + if actor_id and actor_id not in actor_roles: + errors.append(f"{path}.{side_name}.owner_actor_id references unknown actor {actor_id!r}") + if fabric_id and fabric_id not in fabric_kinds: + errors.append(f"{path}.{side_name}.fabric_id references unknown fabric {fabric_id!r}") + subfabric_id = _text(side.get("subfabric_id")) + if subfabric_id and fabric_kinds.get(subfabric_id) != "Subfabric": + errors.append(f"{path}.{side_name}.subfabric_id references unknown subfabric {subfabric_id!r}") + _required_text(errors, f"{path}.utility.utility_type", utility, "utility_type") + for key in ("crosses_fabric_boundary", "crosses_subfabric_boundary"): + if not isinstance(boundary.get(key), bool): + errors.append(f"{path}.boundary.{key} must be a boolean") + + +def _validate_optional_object(errors: list[str], path: str, item: dict[str, Any], key: str) -> None: + if key in item and not isinstance(item[key], dict): + errors.append(f"{path} must be an object") + + +def _required_object(errors: list[str], path: str, item: dict[str, Any], key: str) -> dict[str, Any]: + value = item.get(key) + if not isinstance(value, dict): + errors.append(f"{path} must be an object") + return {} + return value + + +def _required_text(errors: list[str], path: str, item: dict[str, Any], key: str) -> str: + value = _text(item.get(key)) + if not value: + errors.append(f"{path} is required") + return value + + +def _text(value: Any) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _indexed_items(errors: list[str], graph: dict[str, Any], key: str) -> list[tuple[int, dict[str, Any]]]: + value = graph.get(key, []) + if not isinstance(value, list): + errors.append(f"{key} must be an array") + return [] + return [(index, item) for index, item in enumerate(value) if isinstance(item, dict)] + + +def _items(graph: dict[str, Any], key: str) -> list[dict[str, Any]]: + value = graph.get(key, []) + return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else [] + + +def _edge_identity(edge: dict[str, Any]) -> str: + if edge.get("id"): + return str(edge["id"]) + return f"{edge.get('from', '')}:{edge.get('type', '')}:{edge.get('to', '')}" diff --git a/railiance_fabric/registry.py b/railiance_fabric/registry.py index 12aab39..2356f26 100644 --- a/railiance_fabric/registry.py +++ b/railiance_fabric/registry.py @@ -10,6 +10,12 @@ from pathlib import Path from typing import Any from .canon import DISPLAY_ONLY_EDGE_TYPES, edge_canon_mapping, node_canon_mapping +from .financial import ( + financial_graph_errors, + is_financial_graph_export, + materialize_financial_graph_export, + merge_financial_graph_exports, +) from .loader import repo_root from .schema_validation import draft202012_validator @@ -183,6 +189,7 @@ class RegistryStore: if not isinstance(graph, dict): raise RegistryError("snapshot payload requires object field 'graph'") graph = _with_source(graph, repo_slug, commit, generated_at) + graph = materialize_financial_graph_export(graph) validate_graph_export(graph) now = _utc_now() @@ -419,9 +426,15 @@ class RegistryStore: } def combined_graph(self) -> dict[str, Any]: + snapshots = self.latest_snapshots() + if snapshots and all(is_financial_graph_export(snapshot["graph"]) for snapshot in snapshots): + return merge_financial_graph_exports( + [snapshot["graph"] for snapshot in snapshots], + generated_at=_utc_now(), + ) nodes: dict[str, dict[str, Any]] = {} edges: list[dict[str, str]] = [] - for snapshot in self.latest_snapshots(): + for snapshot in snapshots: graph = snapshot["graph"] for node in graph.get("nodes", []): if isinstance(node, dict): @@ -913,6 +926,16 @@ class RegistryStore: def validate_graph_export(graph: dict[str, Any]) -> None: + graph = materialize_financial_graph_export(graph) + if is_financial_graph_export(graph): + errors = financial_graph_errors(graph) + if errors: + raise RegistryError(f"invalid FinancialFabricGraphExport: {errors[0]}") + canon_errors = _graph_canon_metadata_errors(graph) + if canon_errors: + raise RegistryError(f"invalid FinancialFabricGraphExport canon metadata: {canon_errors[0]}") + return + schema_path = repo_root() / "schemas" / "state-hub-export.schema.yaml" validator = draft202012_validator(schema_path) errors = sorted(validator.iter_errors(graph), key=lambda error: list(error.path)) diff --git a/tests/test_registry.py b/tests/test_registry.py index f0092f0..6b28494 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -261,6 +261,48 @@ def test_graph_export_validation_rejects_unflagged_display_edges() -> None: raise AssertionError("expected RegistryError for unflagged display-only edge") +def test_financial_graph_export_requires_resolvable_owner() -> None: + graph = _financial_graph() + del graph["nodes"][0]["ownership"] + + try: + validate_graph_export(graph) + except RegistryError as exc: + assert "ownership must be an object" in exc.message + else: + raise AssertionError("expected RegistryError for accepted node without ownership") + + +def test_registry_accepts_financial_graph_and_materializes_vnext_fields(tmp_path: Path) -> None: + store = RegistryStore(tmp_path / "registry.sqlite3") + store.init_schema() + store.upsert_repository({"slug": "state-hub", "name": "State Hub"}) + + snapshot = store.add_snapshot( + "state-hub", + { + "commit": "financial-vnext", + "generated_at": "2026-05-24T00:00:00Z", + "graph": _financial_graph(), + }, + ) + graph = snapshot["graph"] + edge = graph["edges"][0] + + assert graph["apiVersion"] == "railiance.fabric/v1alpha2" + assert graph["schema_version"] == "financial-fabric-v1" + assert graph["nodes"][0]["evidence"]["review_state"] == "accepted" + assert edge["relationship_category"] == "utility" + assert edge["boundary"]["crosses_fabric_boundary"] is False + assert edge["boundary"]["crosses_subfabric_boundary"] is True + + combined = store.combined_graph() + assert combined["apiVersion"] == "railiance.fabric/v1alpha2" + assert combined["actors"][0]["id"] == "actor.coulomb.tenant" + assert combined["fabrics"][1]["id"] == "subfabric.railiance.tenant.coulomb" + assert combined["edges"][0]["relationship_category"] == "utility" + + def test_registry_reset_archive_and_guarded_reset(tmp_path: Path) -> None: store = RegistryStore(tmp_path / "registry.sqlite3") store.init_schema() @@ -426,6 +468,119 @@ def _post_json(url: str, payload: dict) -> dict: return json.loads(response.read()) +def _financial_graph() -> dict: + return { + "apiVersion": "railiance.fabric/v1alpha2", + "kind": "FabricGraphExport", + "schema_version": "financial-fabric-v1", + "generated_at": "2026-05-24T00:00:00Z", + "netkingdom": { + "id": "railiance.netkingdom", + "name": "Railiance Netkingdom", + "king_actor_id": "actor.railiance.king", + }, + "actors": [ + {"id": "actor.railiance.king", "kind": "FabricActor", "role": "king", "name": "Railiance King"}, + {"id": "actor.railiance.primary-lord", "kind": "FabricActor", "role": "lord", "name": "Railiance Lord"}, + {"id": "actor.coulomb.tenant", "kind": "FabricActor", "role": "tenant", "name": "Coulomb Tenant"}, + ], + "fabrics": [ + { + "id": "fabric.railiance.primary", + "kind": "Fabric", + "name": "Railiance Primary Fabric", + "netkingdom_id": "railiance.netkingdom", + "lord_actor_id": "actor.railiance.primary-lord", + "status": "active", + }, + { + "id": "subfabric.railiance.tenant.coulomb", + "kind": "Subfabric", + "name": "Coulomb Tenant Subfabric", + "netkingdom_id": "railiance.netkingdom", + "parent_fabric_id": "fabric.railiance.primary", + "tenant_actor_id": "actor.coulomb.tenant", + "status": "planned", + }, + ], + "nodes": [ + { + "id": "state-hub.http", + "kind": "UtilityInterface", + "name": "State Hub HTTP API", + "repo": "state-hub", + "domain": "custodian", + "lifecycle": "active", + "containment": { + "netkingdom_id": "railiance.netkingdom", + "fabric_id": "fabric.railiance.primary", + "subfabric_id": None, + "environment": "local", + }, + "ownership": { + "owner_actor_id": "actor.railiance.primary-lord", + "owner_role": "lord", + "resolution": "inherited", + "inherited_from": "fabric.railiance.primary", + }, + }, + { + "id": "coulomb.automation-client", + "kind": "Service", + "name": "Coulomb Automation Client", + "repo": "coulomb-automation", + "domain": "railiance", + "lifecycle": "planned", + "containment": { + "netkingdom_id": "railiance.netkingdom", + "fabric_id": "fabric.railiance.primary", + "subfabric_id": "subfabric.railiance.tenant.coulomb", + "environment": "local", + }, + "ownership": { + "owner_actor_id": "actor.coulomb.tenant", + "owner_role": "tenant", + "resolution": "explicit", + }, + "accounting": { + "cost_center_id": "cc.coulomb.automation", + "allocation_model": "direct", + }, + }, + ], + "edges": [ + { + "id": "utility:state-hub-http:coulomb-client", + "from": "state-hub.http", + "to": "coulomb.automation-client", + "type": "provides_utility_to", + "provider": { + "owner_actor_id": "actor.railiance.primary-lord", + "fabric_id": "fabric.railiance.primary", + "subfabric_id": None, + }, + "consumer": { + "owner_actor_id": "actor.coulomb.tenant", + "fabric_id": "fabric.railiance.primary", + "subfabric_id": "subfabric.railiance.tenant.coulomb", + }, + "utility": { + "utility_type": "coordination_api", + "contract_id": "state-hub.http", + "payment_schema_id": "payment.internal-tenant-access", + "metering_basis": "unknown", + "business_model": "tenant_utility", + }, + "accounting": { + "provider_profit_center_id": "pc.tenant-utilities", + "consumer_cost_center_id": "cc.coulomb.automation", + "allocation_model": "usage_weighted", + }, + } + ], + } + + def _cyclonedx_bom() -> dict: return { "bomFormat": "CycloneDX", diff --git a/workplans/RAIL-FAB-WP-0017-financial-fabric-model-reset.md b/workplans/RAIL-FAB-WP-0017-financial-fabric-model-reset.md index 1366816..3c7cb0a 100644 --- a/workplans/RAIL-FAB-WP-0017-financial-fabric-model-reset.md +++ b/workplans/RAIL-FAB-WP-0017-financial-fabric-model-reset.md @@ -142,7 +142,7 @@ Result: ```task id: RAIL-FAB-WP-0017-T03 -status: todo +status: done priority: high state_hub_task_id: "7ff1c162-f778-4ab4-9e09-a512a54b2f68" ``` @@ -167,6 +167,25 @@ Done when: - invalid missing-owner cases fail or are flagged according to the contract; - existing snapshot/export code can emit the vNext model. +Result: + +- Added `railiance_fabric/financial.py` with `v1alpha2` financial Fabric + graph materialization, validation, and merge helpers. +- Registry snapshot ingestion now materializes financial graphs before + validation while preserving the legacy `v1alpha1` schema path. +- Registry combined graph output can preserve and merge `v1alpha2` actors, + fabrics, nodes, edges, unresolved gaps, ownership, containment, accounting, + and utility metadata when all latest snapshots use the financial contract. +- Added canon mappings for vNext kinds and edge types such as `FabricActor`, + `Fabric`, `Subfabric`, `UtilityInterface`, `CostCenter`, `ProfitCenter`, + `contains`, `owned_by`, `operated_by`, `provides_utility_to`, and accounting + attribution edges. +- Added registry tests covering missing accepted-node ownership rejection, + financial graph snapshot ingestion, utility edge materialization, and + combined `v1alpha2` graph output. +- Verified with `python3 -m pytest tests/test_registry.py -q` and full + `python3 -m pytest`. + ## T04 - Update The State Hub Export Contract ```task