generated from coulomb/repo-seed
feat: validate financial fabric graph exports
This commit is contained in:
@@ -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"),
|
||||
|
||||
400
railiance_fabric/financial.py
Normal file
400
railiance_fabric/financial.py
Normal file
@@ -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', '')}"
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user