Implement financial Fabric vNext read model

This commit is contained in:
2026-05-24 02:52:59 +02:00
parent 52d43304ba
commit f25569d9d4
8 changed files with 1248 additions and 24 deletions

View File

@@ -97,8 +97,15 @@ async def ingest_fabric_graph_export(
source_commit=source.commit if source else None,
source_path=source.path if source else None,
api_version=export.api_version,
schema_version=export.schema_version,
export_kind=export.kind,
exported_at=export.generated_at,
netkingdom_id=export.netkingdom.id if export.netkingdom else None,
king_actor_id=export.netkingdom.king_actor_id if export.netkingdom else None,
actor_count=len(export.actors),
fabric_count=len(export.fabrics),
unresolved_count=len(export.unresolved),
compatibility=export.compatibility,
content_hash=content_hash,
node_count=len(export.nodes),
edge_count=len(export.edges),
@@ -114,6 +121,10 @@ async def ingest_fabric_graph_export(
for node in export.nodes:
raw = node.model_dump(mode="json")
containment = raw.get("containment") if isinstance(raw.get("containment"), dict) else {}
ownership = raw.get("ownership") if isinstance(raw.get("ownership"), dict) else {}
accounting = raw.get("accounting") if isinstance(raw.get("accounting"), dict) else {}
evidence = raw.get("evidence") if isinstance(raw.get("evidence"), dict) else {}
session.add(
FabricGraphNode(
import_id=import_run.id,
@@ -121,14 +132,26 @@ async def ingest_fabric_graph_export(
graph_id=node.id,
kind=node.kind,
name=node.name,
repo_slug=node.repo,
domain_slug=node.domain,
lifecycle=node.lifecycle,
repo_slug=node.repo or "",
domain_slug=node.domain or "",
lifecycle=node.lifecycle or "",
canonical_type=raw.get("canonical_type"),
canon_category=node.canon_category,
canon_anchor=node.canon_anchor,
mapping_fit=node.mapping_fit,
evidence_state=node.evidence_state,
evidence_state=node.evidence_state or evidence.get("state"),
evidence_review_state=evidence.get("review_state"),
evidence_confidence=_float_or_none(evidence.get("confidence")),
netkingdom_id=containment.get("netkingdom_id"),
fabric_id=containment.get("fabric_id"),
subfabric_id=containment.get("subfabric_id"),
environment=containment.get("environment"),
deployment_scenario_id=containment.get("deployment_scenario_id"),
owner_actor_id=ownership.get("owner_actor_id"),
owner_role=ownership.get("owner_role"),
ownership_resolution=ownership.get("resolution"),
cost_center_id=accounting.get("cost_center_id"),
profit_center_id=accounting.get("profit_center_id"),
display_only=bool(raw.get("display_only", False)),
attributes=node.attributes,
raw_json=raw,
@@ -137,6 +160,12 @@ async def ingest_fabric_graph_export(
for edge in export.edges:
raw = edge.model_dump(mode="json", by_alias=True)
provider = raw.get("provider") if isinstance(raw.get("provider"), dict) else {}
consumer = raw.get("consumer") if isinstance(raw.get("consumer"), dict) else {}
boundary = raw.get("boundary") if isinstance(raw.get("boundary"), dict) else {}
utility = raw.get("utility") if isinstance(raw.get("utility"), dict) else {}
accounting = raw.get("accounting") if isinstance(raw.get("accounting"), dict) else {}
evidence = raw.get("evidence") if isinstance(raw.get("evidence"), dict) else {}
session.add(
FabricGraphEdge(
import_id=import_run.id,
@@ -148,7 +177,27 @@ async def ingest_fabric_graph_export(
canonical_type=edge.canonical_type,
canon_anchor=edge.canon_anchor,
mapping_fit=edge.mapping_fit,
evidence_state=edge.evidence_state,
evidence_state=edge.evidence_state or evidence.get("state"),
evidence_review_state=evidence.get("review_state"),
evidence_confidence=_float_or_none(evidence.get("confidence")),
relationship_category=edge.relationship_category,
provider_owner_actor_id=provider.get("owner_actor_id"),
provider_fabric_id=provider.get("fabric_id"),
provider_subfabric_id=provider.get("subfabric_id"),
consumer_owner_actor_id=consumer.get("owner_actor_id"),
consumer_fabric_id=consumer.get("fabric_id"),
consumer_subfabric_id=consumer.get("subfabric_id"),
crosses_fabric_boundary=boundary.get("crosses_fabric_boundary"),
crosses_subfabric_boundary=boundary.get("crosses_subfabric_boundary"),
utility_type=utility.get("utility_type"),
utility_contract_id=utility.get("contract_id"),
utility_payment_schema_id=utility.get("payment_schema_id"),
utility_metering_basis=utility.get("metering_basis"),
utility_business_model=utility.get("business_model"),
cost_center_id=accounting.get("cost_center_id"),
profit_center_id=accounting.get("profit_center_id"),
provider_profit_center_id=accounting.get("provider_profit_center_id"),
consumer_cost_center_id=accounting.get("consumer_cost_center_id"),
display_only=bool(edge.display_only),
attributes=edge.attributes,
raw_json=raw,
@@ -181,6 +230,9 @@ def validate_fabric_graph_export(payload: dict[str, Any]) -> FabricGraphExportPa
message = first.get("msg", "invalid payload")
raise ValueError(f"invalid FabricGraphExport at {location}: {message}") from exc
contract_errors = _contract_errors(export, payload)
if contract_errors:
raise ValueError(f"invalid FabricGraphExport contract: {contract_errors[0]}")
canon_errors = _canon_metadata_errors(export)
if canon_errors:
raise ValueError(f"invalid FabricGraphExport canon metadata: {canon_errors[0]}")
@@ -198,6 +250,10 @@ def edge_key(edge: dict[str, Any]) -> str:
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _float_or_none(value: Any) -> float | None:
return float(value) if isinstance(value, (int, float)) else None
async def record_fabric_graph_error(
session: AsyncSession,
summary: str,
@@ -318,6 +374,180 @@ def _canonical_payload(payload: dict[str, Any]) -> dict[str, Any]:
return canonical
def _contract_errors(export: FabricGraphExportPayload, payload: dict[str, Any]) -> list[str]:
if export.api_version == "railiance.fabric/v1alpha2":
return _financial_contract_errors(export, payload)
return _legacy_contract_errors(export)
def _legacy_contract_errors(export: FabricGraphExportPayload) -> list[str]:
errors: list[str] = []
if export.schema_version:
errors.append("v1alpha1 exports must not set schema_version")
for index, node in enumerate(export.nodes):
_require_fields(
errors,
f"nodes[{index}]",
{
"repo": node.repo,
"domain": node.domain,
"lifecycle": node.lifecycle,
},
("repo", "domain", "lifecycle"),
)
return errors
def _financial_contract_errors(
export: FabricGraphExportPayload, payload: dict[str, Any]
) -> list[str]:
errors: list[str] = []
for field in ("schema_version", "netkingdom", "actors", "fabrics"):
if field not in payload:
errors.append(f"missing required financial export field {field!r}")
if export.schema_version != "financial-fabric-v1":
errors.append("schema_version must be 'financial-fabric-v1' for v1alpha2 exports")
if export.netkingdom is None:
errors.append("netkingdom must be an object for v1alpha2 exports")
netkingdom_id = ""
king_actor_id = ""
else:
netkingdom_id = export.netkingdom.id
king_actor_id = export.netkingdom.king_actor_id
actor_roles: dict[str, str] = {}
for index, actor in enumerate(export.actors):
if actor.id in actor_roles:
errors.append(f"actors[{index}].id {actor.id!r} is duplicated")
actor_roles[actor.id] = actor.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'")
fabric_kinds: dict[str, str] = {}
for index, fabric in enumerate(export.fabrics):
if fabric.id in fabric_kinds:
errors.append(f"fabrics[{index}].id {fabric.id!r} is duplicated")
fabric_kinds[fabric.id] = fabric.kind
if fabric.netkingdom_id != netkingdom_id:
errors.append(f"fabrics[{index}].netkingdom_id must match netkingdom.id")
if fabric.kind == "Fabric":
if not fabric.lord_actor_id:
errors.append(f"fabrics[{index}].lord_actor_id is required for Fabric")
elif actor_roles.get(fabric.lord_actor_id) not in {"lord", "king"}:
errors.append(f"fabrics[{index}].lord_actor_id must reference a lord or king actor")
if fabric.kind == "Subfabric":
if not fabric.parent_fabric_id:
errors.append(f"fabrics[{index}].parent_fabric_id is required for Subfabric")
elif fabric.parent_fabric_id not in fabric_kinds:
errors.append(
f"fabrics[{index}].parent_fabric_id references unknown fabric {fabric.parent_fabric_id!r}"
)
if not fabric.tenant_actor_id:
errors.append(f"fabrics[{index}].tenant_actor_id is required for Subfabric")
elif actor_roles.get(fabric.tenant_actor_id) != "tenant":
errors.append(f"fabrics[{index}].tenant_actor_id must reference a tenant actor")
node_ids: set[str] = set()
for index, node in enumerate(export.nodes):
path = f"nodes[{index}]"
if node.id in node_ids:
errors.append(f"{path}.id {node.id!r} is duplicated")
node_ids.add(node.id)
if node.containment is None:
errors.append(f"{path}.containment must be an object for v1alpha2 exports")
else:
if node.containment.netkingdom_id != netkingdom_id:
errors.append(f"{path}.containment.netkingdom_id must match netkingdom.id")
_validate_fabric_ref(errors, f"{path}.containment.fabric_id", node.containment.fabric_id, fabric_kinds, "Fabric")
if node.containment.subfabric_id:
_validate_fabric_ref(
errors,
f"{path}.containment.subfabric_id",
node.containment.subfabric_id,
fabric_kinds,
"Subfabric",
)
if node.ownership is None:
errors.append(f"{path}.ownership must be an object for v1alpha2 exports")
else:
_validate_actor_ref(errors, f"{path}.ownership.owner_actor_id", node.ownership.owner_actor_id, actor_roles)
if actor_roles.get(node.ownership.owner_actor_id) not in {node.ownership.owner_role, ""}:
errors.append(f"{path}.ownership.owner_role does not match referenced actor role")
if node.evidence is None:
errors.append(f"{path}.evidence must be an object for v1alpha2 exports")
elif (
node.evidence.review_state == "accepted"
and node.ownership
and node.ownership.resolution not in {"explicit", "inherited"}
):
errors.append(f"{path}.ownership.resolution must be explicit or inherited for accepted nodes")
for index, edge in enumerate(export.edges):
path = f"edges[{index}]"
if edge.from_graph_id not in node_ids:
errors.append(f"{path}.from references unknown node {edge.from_graph_id!r}")
if edge.to_graph_id not in node_ids:
errors.append(f"{path}.to references unknown node {edge.to_graph_id!r}")
if edge.relationship_category is None:
errors.append(f"{path}.relationship_category is required for v1alpha2 exports")
if edge.evidence is None:
errors.append(f"{path}.evidence must be an object for v1alpha2 exports")
if edge.edge_type == "provides_utility_to" and edge.relationship_category != "utility":
errors.append(f"{path}.relationship_category must be 'utility' for provides_utility_to edges")
if edge.relationship_category == "utility":
if edge.provider is None:
errors.append(f"{path}.provider is required for utility edges")
else:
_validate_utility_side(errors, f"{path}.provider", edge.provider, actor_roles, fabric_kinds)
if edge.consumer is None:
errors.append(f"{path}.consumer is required for utility edges")
else:
_validate_utility_side(errors, f"{path}.consumer", edge.consumer, actor_roles, fabric_kinds)
if edge.boundary is None:
errors.append(f"{path}.boundary is required for utility edges")
if edge.utility is None:
errors.append(f"{path}.utility is required for utility edges")
return errors
def _validate_actor_ref(
errors: list[str],
path: str,
actor_id: str,
actor_roles: dict[str, str],
) -> None:
if actor_id not in actor_roles:
errors.append(f"{path} references unknown actor {actor_id!r}")
def _validate_fabric_ref(
errors: list[str],
path: str,
fabric_id: str,
fabric_kinds: dict[str, str],
expected_kind: str,
) -> None:
actual_kind = fabric_kinds.get(fabric_id)
if actual_kind is None:
errors.append(f"{path} references unknown fabric {fabric_id!r}")
elif actual_kind != expected_kind:
errors.append(f"{path} must reference a {expected_kind}")
def _validate_utility_side(
errors: list[str],
path: str,
side: Any,
actor_roles: dict[str, str],
fabric_kinds: dict[str, str],
) -> None:
_validate_actor_ref(errors, f"{path}.owner_actor_id", side.owner_actor_id, actor_roles)
if side.fabric_id not in fabric_kinds:
errors.append(f"{path}.fabric_id references unknown fabric {side.fabric_id!r}")
if side.subfabric_id:
_validate_fabric_ref(errors, f"{path}.subfabric_id", side.subfabric_id, fabric_kinds, "Subfabric")
def _canon_metadata_errors(export: FabricGraphExportPayload) -> list[str]:
errors: list[str] = []
for index, node in enumerate(export.nodes):