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

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -26,8 +26,15 @@ class FabricGraphImport(Base, TimestampMixin):
source_commit: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
source_path: Mapped[str | None] = mapped_column(Text, nullable=True)
api_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
schema_version: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
export_kind: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
exported_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
netkingdom_id: Mapped[str | None] = mapped_column(Text, nullable=True)
king_actor_id: Mapped[str | None] = mapped_column(Text, nullable=True)
actor_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
fabric_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
unresolved_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
compatibility: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
content_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
node_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
edge_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
@@ -72,6 +79,18 @@ class FabricGraphNode(Base):
canon_anchor: Mapped[str | None] = mapped_column(Text, nullable=True)
mapping_fit: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
evidence_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
evidence_review_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
evidence_confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
netkingdom_id: Mapped[str | None] = mapped_column(Text, nullable=True)
fabric_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
subfabric_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
environment: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
deployment_scenario_id: Mapped[str | None] = mapped_column(Text, nullable=True)
owner_actor_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
owner_role: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
ownership_resolution: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
cost_center_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
profit_center_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
display_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false", index=True)
attributes: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
raw_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
@@ -103,6 +122,26 @@ class FabricGraphEdge(Base):
canon_anchor: Mapped[str | None] = mapped_column(Text, nullable=True)
mapping_fit: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
evidence_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
evidence_review_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
evidence_confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
relationship_category: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
provider_owner_actor_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
provider_fabric_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
provider_subfabric_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
consumer_owner_actor_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
consumer_fabric_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
consumer_subfabric_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
crosses_fabric_boundary: Mapped[bool | None] = mapped_column(Boolean, nullable=True, index=True)
crosses_subfabric_boundary: Mapped[bool | None] = mapped_column(Boolean, nullable=True, index=True)
utility_type: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
utility_contract_id: Mapped[str | None] = mapped_column(Text, nullable=True)
utility_payment_schema_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
utility_metering_basis: Mapped[str | None] = mapped_column(String(100), nullable=True)
utility_business_model: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
cost_center_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
profit_center_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
provider_profit_center_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
consumer_cost_center_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
display_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false", index=True)
attributes: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
raw_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")

View File

@@ -4,7 +4,7 @@ from typing import Any
import httpx
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
@@ -137,9 +137,18 @@ async def list_graph_nodes(
domain: str | None = None,
repo: str | None = None,
canonical_category: str | None = None,
fabric_id: str | None = None,
subfabric_id: str | None = None,
owner_actor_id: str | None = None,
owner_role: str | None = None,
ownership_resolution: str | None = None,
cost_center_id: str | None = None,
profit_center_id: str | None = None,
evidence_state: str | None = None,
evidence_review_state: str | None = None,
mapping_fit: str | None = None,
kind: str | None = None,
unresolved_ownership: bool | None = None,
limit: int = Query(100, ge=1, le=1000),
session: AsyncSession = Depends(get_session),
) -> list[FabricGraphNodeRead]:
@@ -151,12 +160,40 @@ async def list_graph_nodes(
query = query.where(FabricGraphNode.repo_slug == repo)
if canonical_category:
query = query.where(FabricGraphNode.canon_category == canonical_category)
if fabric_id:
query = query.where(FabricGraphNode.fabric_id == fabric_id)
if subfabric_id:
query = query.where(FabricGraphNode.subfabric_id == subfabric_id)
if owner_actor_id:
query = query.where(FabricGraphNode.owner_actor_id == owner_actor_id)
if owner_role:
query = query.where(FabricGraphNode.owner_role == owner_role)
if ownership_resolution:
query = query.where(FabricGraphNode.ownership_resolution == ownership_resolution)
if cost_center_id:
query = query.where(FabricGraphNode.cost_center_id == cost_center_id)
if profit_center_id:
query = query.where(FabricGraphNode.profit_center_id == profit_center_id)
if evidence_state:
query = query.where(FabricGraphNode.evidence_state == evidence_state)
if evidence_review_state:
query = query.where(FabricGraphNode.evidence_review_state == evidence_review_state)
if mapping_fit:
query = query.where(FabricGraphNode.mapping_fit == mapping_fit)
if kind:
query = query.where(FabricGraphNode.kind == kind)
if unresolved_ownership is True:
query = query.where(
or_(
FabricGraphNode.owner_actor_id.is_(None),
FabricGraphNode.ownership_resolution.in_(("unresolved", "ambiguous")),
)
)
elif unresolved_ownership is False:
query = query.where(
FabricGraphNode.owner_actor_id.is_not(None),
~FabricGraphNode.ownership_resolution.in_(("unresolved", "ambiguous")),
)
query = query.order_by(FabricGraphNode.graph_id).limit(limit)
result = await session.execute(query)
return [FabricGraphNodeRead.model_validate(row) for row in result.scalars().all()]
@@ -167,9 +204,27 @@ async def list_graph_edges(
source_repo_slug: str = "railiance-fabric",
canonical_relationship: str | None = None,
edge_type: str | None = None,
relationship_category: str | None = None,
provider_owner_actor_id: str | None = None,
consumer_owner_actor_id: str | None = None,
provider_fabric_id: str | None = None,
consumer_fabric_id: str | None = None,
provider_subfabric_id: str | None = None,
consumer_subfabric_id: str | None = None,
crosses_fabric_boundary: bool | None = None,
crosses_subfabric_boundary: bool | None = None,
utility_type: str | None = None,
utility_business_model: str | None = None,
utility_payment_schema_id: str | None = None,
cost_center_id: str | None = None,
profit_center_id: str | None = None,
provider_profit_center_id: str | None = None,
consumer_cost_center_id: str | None = None,
evidence_state: str | None = None,
evidence_review_state: str | None = None,
mapping_fit: str | None = None,
display_only: bool | None = None,
missing_payment_schema: bool | None = None,
from_graph_id: str | None = None,
to_graph_id: str | None = None,
limit: int = Query(100, ge=1, le=1000),
@@ -181,12 +236,58 @@ async def list_graph_edges(
query = query.where(FabricGraphEdge.canonical_type == canonical_relationship)
if edge_type:
query = query.where(FabricGraphEdge.edge_type == edge_type)
if relationship_category:
query = query.where(FabricGraphEdge.relationship_category == relationship_category)
if provider_owner_actor_id:
query = query.where(FabricGraphEdge.provider_owner_actor_id == provider_owner_actor_id)
if consumer_owner_actor_id:
query = query.where(FabricGraphEdge.consumer_owner_actor_id == consumer_owner_actor_id)
if provider_fabric_id:
query = query.where(FabricGraphEdge.provider_fabric_id == provider_fabric_id)
if consumer_fabric_id:
query = query.where(FabricGraphEdge.consumer_fabric_id == consumer_fabric_id)
if provider_subfabric_id:
query = query.where(FabricGraphEdge.provider_subfabric_id == provider_subfabric_id)
if consumer_subfabric_id:
query = query.where(FabricGraphEdge.consumer_subfabric_id == consumer_subfabric_id)
if crosses_fabric_boundary is not None:
query = query.where(FabricGraphEdge.crosses_fabric_boundary == crosses_fabric_boundary)
if crosses_subfabric_boundary is not None:
query = query.where(FabricGraphEdge.crosses_subfabric_boundary == crosses_subfabric_boundary)
if utility_type:
query = query.where(FabricGraphEdge.utility_type == utility_type)
if utility_business_model:
query = query.where(FabricGraphEdge.utility_business_model == utility_business_model)
if utility_payment_schema_id:
query = query.where(FabricGraphEdge.utility_payment_schema_id == utility_payment_schema_id)
if cost_center_id:
query = query.where(FabricGraphEdge.cost_center_id == cost_center_id)
if profit_center_id:
query = query.where(FabricGraphEdge.profit_center_id == profit_center_id)
if provider_profit_center_id:
query = query.where(FabricGraphEdge.provider_profit_center_id == provider_profit_center_id)
if consumer_cost_center_id:
query = query.where(FabricGraphEdge.consumer_cost_center_id == consumer_cost_center_id)
if evidence_state:
query = query.where(FabricGraphEdge.evidence_state == evidence_state)
if evidence_review_state:
query = query.where(FabricGraphEdge.evidence_review_state == evidence_review_state)
if mapping_fit:
query = query.where(FabricGraphEdge.mapping_fit == mapping_fit)
if display_only is not None:
query = query.where(FabricGraphEdge.display_only == display_only)
if missing_payment_schema is True:
query = query.where(
FabricGraphEdge.relationship_category == "utility",
FabricGraphEdge.utility_payment_schema_id.is_(None),
)
elif missing_payment_schema is False:
query = query.where(
or_(
FabricGraphEdge.relationship_category != "utility",
FabricGraphEdge.utility_payment_schema_id.is_not(None),
)
)
if from_graph_id:
query = query.where(FabricGraphEdge.from_graph_id == from_graph_id)
if to_graph_id:
@@ -208,14 +309,32 @@ async def graph_summary(
latest_import=None,
node_count=0,
edge_count=0,
schema_version=None,
netkingdom_id=None,
actor_count=0,
fabric_count=0,
unresolved_count=0,
nodes_by_domain={},
nodes_by_repo={},
nodes_by_canon_category={},
nodes_by_fabric={},
nodes_by_subfabric={},
nodes_by_owner_actor={},
nodes_by_owner_role={},
nodes_by_ownership_resolution={},
edges_by_canonical_type={},
edges_by_relationship_category={},
utility_edges_by_provider_owner={},
utility_edges_by_consumer_owner={},
utility_edges_by_business_model={},
nodes_by_evidence_state={},
edges_by_evidence_state={},
nodes_by_mapping_fit={},
edges_by_mapping_fit={},
tenant_utilities_without_payment_schema=0,
nodes_without_accounting_attribution=0,
unresolved_ownership_count=0,
unresolved_accounting_count=0,
example_nodes=[],
example_edges=[],
)
@@ -225,14 +344,74 @@ async def graph_summary(
latest_import=FabricGraphImportRead.model_validate(import_run),
node_count=import_run.node_count,
edge_count=import_run.edge_count,
schema_version=import_run.schema_version,
netkingdom_id=import_run.netkingdom_id,
actor_count=import_run.actor_count,
fabric_count=import_run.fabric_count,
unresolved_count=import_run.unresolved_count,
nodes_by_domain=await _counts(session, FabricGraphNode.domain_slug, import_run.id),
nodes_by_repo=await _counts(session, FabricGraphNode.repo_slug, import_run.id),
nodes_by_canon_category=await _counts(session, FabricGraphNode.canon_category, import_run.id),
nodes_by_fabric=await _counts(session, FabricGraphNode.fabric_id, import_run.id),
nodes_by_subfabric=await _counts(session, FabricGraphNode.subfabric_id, import_run.id),
nodes_by_owner_actor=await _counts(session, FabricGraphNode.owner_actor_id, import_run.id),
nodes_by_owner_role=await _counts(session, FabricGraphNode.owner_role, import_run.id),
nodes_by_ownership_resolution=await _counts(session, FabricGraphNode.ownership_resolution, import_run.id),
edges_by_canonical_type=await _counts(session, FabricGraphEdge.canonical_type, import_run.id),
edges_by_relationship_category=await _counts(session, FabricGraphEdge.relationship_category, import_run.id),
utility_edges_by_provider_owner=await _counts(
session,
FabricGraphEdge.provider_owner_actor_id,
import_run.id,
FabricGraphEdge.relationship_category == "utility",
),
utility_edges_by_consumer_owner=await _counts(
session,
FabricGraphEdge.consumer_owner_actor_id,
import_run.id,
FabricGraphEdge.relationship_category == "utility",
),
utility_edges_by_business_model=await _counts(
session,
FabricGraphEdge.utility_business_model,
import_run.id,
FabricGraphEdge.relationship_category == "utility",
),
nodes_by_evidence_state=await _counts(session, FabricGraphNode.evidence_state, import_run.id),
edges_by_evidence_state=await _counts(session, FabricGraphEdge.evidence_state, import_run.id),
nodes_by_mapping_fit=await _counts(session, FabricGraphNode.mapping_fit, import_run.id),
edges_by_mapping_fit=await _counts(session, FabricGraphEdge.mapping_fit, import_run.id),
tenant_utilities_without_payment_schema=await _count_rows(
session,
FabricGraphEdge,
import_run.id,
FabricGraphEdge.relationship_category == "utility",
FabricGraphEdge.utility_business_model == "tenant_utility",
FabricGraphEdge.utility_payment_schema_id.is_(None),
),
nodes_without_accounting_attribution=await _count_rows(
session,
FabricGraphNode,
import_run.id,
FabricGraphNode.cost_center_id.is_(None),
FabricGraphNode.profit_center_id.is_(None),
),
unresolved_ownership_count=await _count_rows(
session,
FabricGraphNode,
import_run.id,
or_(
FabricGraphNode.owner_actor_id.is_(None),
FabricGraphNode.ownership_resolution.in_(("unresolved", "ambiguous")),
),
),
unresolved_accounting_count=await _count_rows(
session,
FabricGraphEdge,
import_run.id,
FabricGraphEdge.relationship_category == "utility",
FabricGraphEdge.utility_payment_schema_id.is_(None),
),
example_nodes=await _example_nodes(session, import_run.id),
example_edges=await _example_edges(session, import_run.id),
)
@@ -259,17 +438,24 @@ async def _latest_valid_import_or_404(session: AsyncSession, source_repo_slug: s
return import_run
async def _counts(session: AsyncSession, column: Any, import_id: Any) -> dict[str, int]:
async def _counts(session: AsyncSession, column: Any, import_id: Any, *conditions: Any) -> dict[str, int]:
table = column.class_
result = await session.execute(
select(column, func.count())
.select_from(table)
.where(table.import_id == import_id, column.is_not(None))
.where(table.import_id == import_id, column.is_not(None), *conditions)
.group_by(column)
)
return {str(key): int(count) for key, count in result.all() if key is not None}
async def _count_rows(session: AsyncSession, table: Any, import_id: Any, *conditions: Any) -> int:
result = await session.execute(
select(func.count()).select_from(table).where(table.import_id == import_id, *conditions)
)
return int(result.scalar_one())
async def _example_nodes(session: AsyncSession, import_id: Any) -> list[FabricGraphNodeRead]:
result = await session.execute(
select(FabricGraphNode)

View File

@@ -7,14 +7,139 @@ from pydantic import BaseModel, ConfigDict, Field
MappingFit = Literal["direct", "partial", "conflict", "gap", "unknown"]
EvidenceState = Literal["observed", "declared", "inferred", "proposed", "gap"]
FabricGraphApiVersion = Literal["railiance.fabric/v1alpha1", "railiance.fabric/v1alpha2"]
ActorRole = Literal["king", "lord", "tenant", "operator", "steward"]
OwnershipResolution = Literal["explicit", "inherited", "unresolved", "ambiguous"]
EvidenceReviewState = Literal["accepted", "candidate", "needs_review", "rejected"]
RelationshipCategory = Literal[
"containment",
"ownership",
"technical",
"utility",
"accounting",
"evidence",
]
class FabricGraphSource(BaseModel):
model_config = ConfigDict(extra="forbid")
model_config = ConfigDict(extra="allow")
repo: str | None = None
producer: str | None = None
registry: str | None = None
commit: str | None = None
path: str | None = None
generation_reason: str | None = None
class FabricGraphNetkingdomPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str
name: str
king_actor_id: str
class FabricGraphActorPayload(BaseModel):
model_config = ConfigDict(extra="allow")
id: str
kind: Literal["FabricActor"]
role: ActorRole
name: str
class FabricGraphFabricPayload(BaseModel):
model_config = ConfigDict(extra="allow")
id: str
kind: Literal["Fabric", "Subfabric"]
name: str
netkingdom_id: str
parent_fabric_id: str | None = None
lord_actor_id: str | None = None
tenant_actor_id: str | None = None
status: str
boundary: dict[str, Any] = Field(default_factory=dict)
evidence_refs: list[dict[str, Any]] = Field(default_factory=list)
class FabricGraphContainmentPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
netkingdom_id: str
fabric_id: str
subfabric_id: str | None = None
environment: str | None = None
deployment_scenario_id: str | None = None
class FabricGraphOwnershipPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
owner_actor_id: str
owner_role: ActorRole
resolution: OwnershipResolution
inherited_from: str | None = None
supporting_actor_ids: list[str] = Field(default_factory=list)
class FabricGraphAccountingPayload(BaseModel):
model_config = ConfigDict(extra="allow")
cost_center_id: str | None = None
profit_center_id: str | None = None
provider_profit_center_id: str | None = None
consumer_cost_center_id: str | None = None
allocation_model: str | None = None
payment_schema_id: str | None = None
metering_basis: str | None = None
valid_from: str | None = None
valid_until: str | None = None
class FabricGraphEvidencePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
state: EvidenceState
review_state: EvidenceReviewState
confidence: float | None = Field(default=None, ge=0, le=1)
refs: list[dict[str, Any]] = Field(default_factory=list)
class FabricGraphUtilitySidePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
owner_actor_id: str
fabric_id: str
subfabric_id: str | None = None
class FabricGraphBoundaryPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
crosses_fabric_boundary: bool
crosses_subfabric_boundary: bool
class FabricGraphUtilityPayload(BaseModel):
model_config = ConfigDict(extra="allow")
utility_type: str
contract_id: str | None = None
payment_schema_id: str | None = None
metering_basis: str | None = None
business_model: str | None = None
class FabricGraphUnresolvedPayload(BaseModel):
model_config = ConfigDict(extra="allow")
target_id: str
kind: str
severity: str | None = None
message: str
evidence_refs: list[dict[str, Any]] = Field(default_factory=list)
class FabricGraphNodePayload(BaseModel):
@@ -23,9 +148,13 @@ class FabricGraphNodePayload(BaseModel):
id: str
kind: str
name: str
repo: str
domain: str
lifecycle: str
repo: str | None = None
domain: str | None = None
lifecycle: str | None = None
containment: FabricGraphContainmentPayload | None = None
ownership: FabricGraphOwnershipPayload | None = None
accounting: FabricGraphAccountingPayload | None = None
evidence: FabricGraphEvidencePayload | None = None
canon_category: str | None = None
canon_anchor: str | None = None
mapping_fit: MappingFit | None = None
@@ -39,23 +168,37 @@ class FabricGraphEdgePayload(BaseModel):
from_graph_id: str = Field(alias="from")
to_graph_id: str = Field(alias="to")
edge_type: str = Field(alias="type")
id: str | None = None
relationship_category: RelationshipCategory | None = None
canonical_type: str | None = None
canon_anchor: str | None = None
mapping_fit: MappingFit | None = None
display_only: bool | None = None
evidence_state: EvidenceState | None = None
provider: FabricGraphUtilitySidePayload | None = None
consumer: FabricGraphUtilitySidePayload | None = None
boundary: FabricGraphBoundaryPayload | None = None
utility: FabricGraphUtilityPayload | None = None
accounting: FabricGraphAccountingPayload | None = None
evidence: FabricGraphEvidencePayload | None = None
attributes: dict[str, Any] = Field(default_factory=dict)
class FabricGraphExportPayload(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
api_version: Literal["railiance.fabric/v1alpha1"] = Field(alias="apiVersion")
api_version: FabricGraphApiVersion = Field(alias="apiVersion")
kind: Literal["FabricGraphExport"]
schema_version: Literal["financial-fabric-v1"] | None = None
generated_at: datetime | None = None
source: FabricGraphSource | None = None
compatibility: dict[str, Any] = Field(default_factory=dict)
netkingdom: FabricGraphNetkingdomPayload | None = None
actors: list[FabricGraphActorPayload] = Field(default_factory=list)
fabrics: list[FabricGraphFabricPayload] = Field(default_factory=list)
nodes: list[FabricGraphNodePayload]
edges: list[FabricGraphEdgePayload]
unresolved: list[FabricGraphUnresolvedPayload] = Field(default_factory=list)
class FabricGraphPullRequest(BaseModel):
@@ -73,8 +216,15 @@ class FabricGraphImportRead(BaseModel):
source_commit: str | None = None
source_path: str | None = None
api_version: str | None = None
schema_version: str | None = None
export_kind: str | None = None
exported_at: datetime | None = None
netkingdom_id: str | None = None
king_actor_id: str | None = None
actor_count: int
fabric_count: int
unresolved_count: int
compatibility: dict
content_hash: str
node_count: int
edge_count: int
@@ -111,6 +261,18 @@ class FabricGraphNodeRead(BaseModel):
canon_anchor: str | None = None
mapping_fit: str | None = None
evidence_state: str | None = None
evidence_review_state: str | None = None
evidence_confidence: float | None = None
netkingdom_id: str | None = None
fabric_id: str | None = None
subfabric_id: str | None = None
environment: str | None = None
deployment_scenario_id: str | None = None
owner_actor_id: str | None = None
owner_role: str | None = None
ownership_resolution: str | None = None
cost_center_id: str | None = None
profit_center_id: str | None = None
display_only: bool
attributes: dict
raw_json: dict
@@ -130,6 +292,26 @@ class FabricGraphEdgeRead(BaseModel):
canon_anchor: str | None = None
mapping_fit: str | None = None
evidence_state: str | None = None
evidence_review_state: str | None = None
evidence_confidence: float | None = None
relationship_category: str | None = None
provider_owner_actor_id: str | None = None
provider_fabric_id: str | None = None
provider_subfabric_id: str | None = None
consumer_owner_actor_id: str | None = None
consumer_fabric_id: str | None = None
consumer_subfabric_id: str | None = None
crosses_fabric_boundary: bool | None = None
crosses_subfabric_boundary: bool | None = None
utility_type: str | None = None
utility_contract_id: str | None = None
utility_payment_schema_id: str | None = None
utility_metering_basis: str | None = None
utility_business_model: str | None = None
cost_center_id: str | None = None
profit_center_id: str | None = None
provider_profit_center_id: str | None = None
consumer_cost_center_id: str | None = None
display_only: bool
attributes: dict
raw_json: dict
@@ -140,13 +322,31 @@ class FabricGraphSummary(BaseModel):
latest_import: FabricGraphImportRead | None = None
node_count: int
edge_count: int
schema_version: str | None = None
netkingdom_id: str | None = None
actor_count: int
fabric_count: int
unresolved_count: int
nodes_by_domain: dict[str, int]
nodes_by_repo: dict[str, int]
nodes_by_canon_category: dict[str, int]
nodes_by_fabric: dict[str, int]
nodes_by_subfabric: dict[str, int]
nodes_by_owner_actor: dict[str, int]
nodes_by_owner_role: dict[str, int]
nodes_by_ownership_resolution: dict[str, int]
edges_by_canonical_type: dict[str, int]
edges_by_relationship_category: dict[str, int]
utility_edges_by_provider_owner: dict[str, int]
utility_edges_by_consumer_owner: dict[str, int]
utility_edges_by_business_model: dict[str, int]
nodes_by_evidence_state: dict[str, int]
edges_by_evidence_state: dict[str, int]
nodes_by_mapping_fit: dict[str, int]
edges_by_mapping_fit: dict[str, int]
tenant_utilities_without_payment_schema: int
nodes_without_accounting_attribution: int
unresolved_ownership_count: int
unresolved_accounting_count: int
example_nodes: list[FabricGraphNodeRead]
example_edges: list[FabricGraphEdgeRead]

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):