generated from coulomb/repo-seed
Implement financial Fabric vNext read model
This commit is contained in:
@@ -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="{}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
State Hub stores Railiance Fabric graph exports as a read model. Fabric remains
|
||||
the source of truth; the State Hub tables are cache/index/query state for
|
||||
dashboard and operator use.
|
||||
dashboard and operator use. State Hub accepts both the legacy
|
||||
`railiance.fabric/v1alpha1` export and the financial Fabric
|
||||
`railiance.fabric/v1alpha2` export with `schema_version:
|
||||
financial-fabric-v1`.
|
||||
|
||||
## Refresh After Fabric Reset/Reingest
|
||||
|
||||
@@ -32,6 +35,23 @@ curl -s -X POST "http://127.0.0.1:8000/fabric/graph-exports?source_repo_slug=rai
|
||||
--data-binary @fabric-state-hub-export.json
|
||||
```
|
||||
|
||||
For the financial Fabric export, generate or save the vNext payload from
|
||||
`railiance-fabric`:
|
||||
|
||||
```bash
|
||||
railiance-fabric validate .
|
||||
railiance-fabric export --format financial > financial-fabric-v1.json
|
||||
curl -s -X POST "http://127.0.0.1:8000/fabric/graph-exports?source_repo_slug=railiance-fabric" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @financial-fabric-v1.json
|
||||
```
|
||||
|
||||
The vNext import preserves the full `graph_json` payload and materializes
|
||||
dashboard fields for netkingdom, actors, fabrics/subfabrics, containment,
|
||||
ownership, utility edges, boundary crossing, evidence review state, and
|
||||
accounting attribution. Legacy imports remain readable; vNext-only fields are
|
||||
`null` or empty for old rows.
|
||||
|
||||
## Query Checks
|
||||
|
||||
Latest import and counts:
|
||||
@@ -57,6 +77,24 @@ curl -s "http://127.0.0.1:8000/fabric/graph/nodes?domain=custodian"
|
||||
curl -s "http://127.0.0.1:8000/fabric/graph/nodes?canonical_category=service"
|
||||
```
|
||||
|
||||
Financial Fabric filters:
|
||||
|
||||
```bash
|
||||
curl -s "http://127.0.0.1:8000/fabric/graph/nodes?fabric_id=fabric.railiance.primary"
|
||||
curl -s "http://127.0.0.1:8000/fabric/graph/nodes?subfabric_id=subfabric.railiance.tenant.coulomb"
|
||||
curl -s "http://127.0.0.1:8000/fabric/graph/nodes?owner_role=tenant"
|
||||
curl -s "http://127.0.0.1:8000/fabric/graph/nodes?unresolved_ownership=true"
|
||||
curl -s "http://127.0.0.1:8000/fabric/graph/edges?relationship_category=utility"
|
||||
curl -s "http://127.0.0.1:8000/fabric/graph/edges?consumer_owner_actor_id=actor.coulomb.tenant"
|
||||
curl -s "http://127.0.0.1:8000/fabric/graph/edges?crosses_subfabric_boundary=true"
|
||||
curl -s "http://127.0.0.1:8000/fabric/graph/edges?missing_payment_schema=true"
|
||||
```
|
||||
|
||||
Summary output includes financial dashboard counters such as nodes by fabric,
|
||||
subfabric, owner actor, owner role, ownership resolution, utility edges by
|
||||
provider/consumer owner, tenant utility edges missing payment schema, nodes
|
||||
without cost/profit attribution, and unresolved ownership/accounting counts.
|
||||
|
||||
## Guarantees
|
||||
|
||||
- malformed exports create a failed import/progress record but do not write
|
||||
@@ -65,3 +103,6 @@ curl -s "http://127.0.0.1:8000/fabric/graph/nodes?canonical_category=service"
|
||||
- latest selection is per `source_repo_slug`
|
||||
- read endpoints do not mutate workstreams, tasks, messages, decisions, or
|
||||
progress rows
|
||||
- State Hub does not synthesize Fabric ownership; unresolved or ambiguous
|
||||
ownership/accounting markers must come from the Fabric export and remain
|
||||
visible as read-model gaps
|
||||
|
||||
166
migrations/versions/z3u4v5w6x7y8_financial_fabric_vnext.py
Normal file
166
migrations/versions/z3u4v5w6x7y8_financial_fabric_vnext.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""extend Fabric graph read model for financial Fabric vNext
|
||||
|
||||
Revision ID: z3u4v5w6x7y8
|
||||
Revises: y2t3u4v5w6x7
|
||||
Create Date: 2026-05-24
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision = "z3u4v5w6x7y8"
|
||||
down_revision = "y2t3u4v5w6x7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("fabric_graph_imports", sa.Column("schema_version", sa.String(length=100), nullable=True))
|
||||
op.add_column("fabric_graph_imports", sa.Column("netkingdom_id", sa.Text(), nullable=True))
|
||||
op.add_column("fabric_graph_imports", sa.Column("king_actor_id", sa.Text(), nullable=True))
|
||||
op.add_column("fabric_graph_imports", sa.Column("actor_count", sa.Integer(), nullable=False, server_default="0"))
|
||||
op.add_column("fabric_graph_imports", sa.Column("fabric_count", sa.Integer(), nullable=False, server_default="0"))
|
||||
op.add_column("fabric_graph_imports", sa.Column("unresolved_count", sa.Integer(), nullable=False, server_default="0"))
|
||||
op.add_column("fabric_graph_imports", sa.Column("compatibility", JSONB(), nullable=False, server_default="{}"))
|
||||
op.create_index("ix_fabric_graph_imports_schema_version", "fabric_graph_imports", ["schema_version"])
|
||||
|
||||
op.add_column("fabric_graph_nodes", sa.Column("evidence_review_state", sa.String(length=20), nullable=True))
|
||||
op.add_column("fabric_graph_nodes", sa.Column("evidence_confidence", sa.Float(), nullable=True))
|
||||
op.add_column("fabric_graph_nodes", sa.Column("netkingdom_id", sa.Text(), nullable=True))
|
||||
op.add_column("fabric_graph_nodes", sa.Column("fabric_id", sa.Text(), nullable=True))
|
||||
op.add_column("fabric_graph_nodes", sa.Column("subfabric_id", sa.Text(), nullable=True))
|
||||
op.add_column("fabric_graph_nodes", sa.Column("environment", sa.String(length=100), nullable=True))
|
||||
op.add_column("fabric_graph_nodes", sa.Column("deployment_scenario_id", sa.Text(), nullable=True))
|
||||
op.add_column("fabric_graph_nodes", sa.Column("owner_actor_id", sa.Text(), nullable=True))
|
||||
op.add_column("fabric_graph_nodes", sa.Column("owner_role", sa.String(length=20), nullable=True))
|
||||
op.add_column("fabric_graph_nodes", sa.Column("ownership_resolution", sa.String(length=20), nullable=True))
|
||||
op.add_column("fabric_graph_nodes", sa.Column("cost_center_id", sa.Text(), nullable=True))
|
||||
op.add_column("fabric_graph_nodes", sa.Column("profit_center_id", sa.Text(), nullable=True))
|
||||
op.create_index("ix_fabric_graph_nodes_evidence_review_state", "fabric_graph_nodes", ["evidence_review_state"])
|
||||
op.create_index("ix_fabric_graph_nodes_fabric_id", "fabric_graph_nodes", ["fabric_id"])
|
||||
op.create_index("ix_fabric_graph_nodes_subfabric_id", "fabric_graph_nodes", ["subfabric_id"])
|
||||
op.create_index("ix_fabric_graph_nodes_environment", "fabric_graph_nodes", ["environment"])
|
||||
op.create_index("ix_fabric_graph_nodes_owner_actor_id", "fabric_graph_nodes", ["owner_actor_id"])
|
||||
op.create_index("ix_fabric_graph_nodes_owner_role", "fabric_graph_nodes", ["owner_role"])
|
||||
op.create_index("ix_fabric_graph_nodes_ownership_resolution", "fabric_graph_nodes", ["ownership_resolution"])
|
||||
op.create_index("ix_fabric_graph_nodes_cost_center_id", "fabric_graph_nodes", ["cost_center_id"])
|
||||
op.create_index("ix_fabric_graph_nodes_profit_center_id", "fabric_graph_nodes", ["profit_center_id"])
|
||||
|
||||
op.add_column("fabric_graph_edges", sa.Column("evidence_review_state", sa.String(length=20), nullable=True))
|
||||
op.add_column("fabric_graph_edges", sa.Column("evidence_confidence", sa.Float(), nullable=True))
|
||||
op.add_column("fabric_graph_edges", sa.Column("relationship_category", sa.String(length=30), nullable=True))
|
||||
op.add_column("fabric_graph_edges", sa.Column("provider_owner_actor_id", sa.Text(), nullable=True))
|
||||
op.add_column("fabric_graph_edges", sa.Column("provider_fabric_id", sa.Text(), nullable=True))
|
||||
op.add_column("fabric_graph_edges", sa.Column("provider_subfabric_id", sa.Text(), nullable=True))
|
||||
op.add_column("fabric_graph_edges", sa.Column("consumer_owner_actor_id", sa.Text(), nullable=True))
|
||||
op.add_column("fabric_graph_edges", sa.Column("consumer_fabric_id", sa.Text(), nullable=True))
|
||||
op.add_column("fabric_graph_edges", sa.Column("consumer_subfabric_id", sa.Text(), nullable=True))
|
||||
op.add_column("fabric_graph_edges", sa.Column("crosses_fabric_boundary", sa.Boolean(), nullable=True))
|
||||
op.add_column("fabric_graph_edges", sa.Column("crosses_subfabric_boundary", sa.Boolean(), nullable=True))
|
||||
op.add_column("fabric_graph_edges", sa.Column("utility_type", sa.String(length=100), nullable=True))
|
||||
op.add_column("fabric_graph_edges", sa.Column("utility_contract_id", sa.Text(), nullable=True))
|
||||
op.add_column("fabric_graph_edges", sa.Column("utility_payment_schema_id", sa.Text(), nullable=True))
|
||||
op.add_column("fabric_graph_edges", sa.Column("utility_metering_basis", sa.String(length=100), nullable=True))
|
||||
op.add_column("fabric_graph_edges", sa.Column("utility_business_model", sa.String(length=100), nullable=True))
|
||||
op.add_column("fabric_graph_edges", sa.Column("cost_center_id", sa.Text(), nullable=True))
|
||||
op.add_column("fabric_graph_edges", sa.Column("profit_center_id", sa.Text(), nullable=True))
|
||||
op.add_column("fabric_graph_edges", sa.Column("provider_profit_center_id", sa.Text(), nullable=True))
|
||||
op.add_column("fabric_graph_edges", sa.Column("consumer_cost_center_id", sa.Text(), nullable=True))
|
||||
op.create_index("ix_fabric_graph_edges_evidence_review_state", "fabric_graph_edges", ["evidence_review_state"])
|
||||
op.create_index("ix_fabric_graph_edges_relationship_category", "fabric_graph_edges", ["relationship_category"])
|
||||
op.create_index("ix_fabric_graph_edges_provider_owner_actor_id", "fabric_graph_edges", ["provider_owner_actor_id"])
|
||||
op.create_index("ix_fabric_graph_edges_provider_fabric_id", "fabric_graph_edges", ["provider_fabric_id"])
|
||||
op.create_index("ix_fabric_graph_edges_provider_subfabric_id", "fabric_graph_edges", ["provider_subfabric_id"])
|
||||
op.create_index("ix_fabric_graph_edges_consumer_owner_actor_id", "fabric_graph_edges", ["consumer_owner_actor_id"])
|
||||
op.create_index("ix_fabric_graph_edges_consumer_fabric_id", "fabric_graph_edges", ["consumer_fabric_id"])
|
||||
op.create_index("ix_fabric_graph_edges_consumer_subfabric_id", "fabric_graph_edges", ["consumer_subfabric_id"])
|
||||
op.create_index("ix_fabric_graph_edges_crosses_fabric_boundary", "fabric_graph_edges", ["crosses_fabric_boundary"])
|
||||
op.create_index("ix_fabric_graph_edges_crosses_subfabric_boundary", "fabric_graph_edges", ["crosses_subfabric_boundary"])
|
||||
op.create_index("ix_fabric_graph_edges_utility_type", "fabric_graph_edges", ["utility_type"])
|
||||
op.create_index("ix_fabric_graph_edges_utility_payment_schema_id", "fabric_graph_edges", ["utility_payment_schema_id"])
|
||||
op.create_index("ix_fabric_graph_edges_utility_business_model", "fabric_graph_edges", ["utility_business_model"])
|
||||
op.create_index("ix_fabric_graph_edges_cost_center_id", "fabric_graph_edges", ["cost_center_id"])
|
||||
op.create_index("ix_fabric_graph_edges_profit_center_id", "fabric_graph_edges", ["profit_center_id"])
|
||||
op.create_index("ix_fabric_graph_edges_provider_profit_center_id", "fabric_graph_edges", ["provider_profit_center_id"])
|
||||
op.create_index("ix_fabric_graph_edges_consumer_cost_center_id", "fabric_graph_edges", ["consumer_cost_center_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_fabric_graph_edges_consumer_cost_center_id", table_name="fabric_graph_edges")
|
||||
op.drop_index("ix_fabric_graph_edges_provider_profit_center_id", table_name="fabric_graph_edges")
|
||||
op.drop_index("ix_fabric_graph_edges_profit_center_id", table_name="fabric_graph_edges")
|
||||
op.drop_index("ix_fabric_graph_edges_cost_center_id", table_name="fabric_graph_edges")
|
||||
op.drop_index("ix_fabric_graph_edges_utility_business_model", table_name="fabric_graph_edges")
|
||||
op.drop_index("ix_fabric_graph_edges_utility_payment_schema_id", table_name="fabric_graph_edges")
|
||||
op.drop_index("ix_fabric_graph_edges_utility_type", table_name="fabric_graph_edges")
|
||||
op.drop_index("ix_fabric_graph_edges_crosses_subfabric_boundary", table_name="fabric_graph_edges")
|
||||
op.drop_index("ix_fabric_graph_edges_crosses_fabric_boundary", table_name="fabric_graph_edges")
|
||||
op.drop_index("ix_fabric_graph_edges_consumer_subfabric_id", table_name="fabric_graph_edges")
|
||||
op.drop_index("ix_fabric_graph_edges_consumer_fabric_id", table_name="fabric_graph_edges")
|
||||
op.drop_index("ix_fabric_graph_edges_consumer_owner_actor_id", table_name="fabric_graph_edges")
|
||||
op.drop_index("ix_fabric_graph_edges_provider_subfabric_id", table_name="fabric_graph_edges")
|
||||
op.drop_index("ix_fabric_graph_edges_provider_fabric_id", table_name="fabric_graph_edges")
|
||||
op.drop_index("ix_fabric_graph_edges_provider_owner_actor_id", table_name="fabric_graph_edges")
|
||||
op.drop_index("ix_fabric_graph_edges_relationship_category", table_name="fabric_graph_edges")
|
||||
op.drop_index("ix_fabric_graph_edges_evidence_review_state", table_name="fabric_graph_edges")
|
||||
for column in (
|
||||
"consumer_cost_center_id",
|
||||
"provider_profit_center_id",
|
||||
"profit_center_id",
|
||||
"cost_center_id",
|
||||
"utility_business_model",
|
||||
"utility_metering_basis",
|
||||
"utility_payment_schema_id",
|
||||
"utility_contract_id",
|
||||
"utility_type",
|
||||
"crosses_subfabric_boundary",
|
||||
"crosses_fabric_boundary",
|
||||
"consumer_subfabric_id",
|
||||
"consumer_fabric_id",
|
||||
"consumer_owner_actor_id",
|
||||
"provider_subfabric_id",
|
||||
"provider_fabric_id",
|
||||
"provider_owner_actor_id",
|
||||
"relationship_category",
|
||||
"evidence_confidence",
|
||||
"evidence_review_state",
|
||||
):
|
||||
op.drop_column("fabric_graph_edges", column)
|
||||
|
||||
op.drop_index("ix_fabric_graph_nodes_profit_center_id", table_name="fabric_graph_nodes")
|
||||
op.drop_index("ix_fabric_graph_nodes_cost_center_id", table_name="fabric_graph_nodes")
|
||||
op.drop_index("ix_fabric_graph_nodes_ownership_resolution", table_name="fabric_graph_nodes")
|
||||
op.drop_index("ix_fabric_graph_nodes_owner_role", table_name="fabric_graph_nodes")
|
||||
op.drop_index("ix_fabric_graph_nodes_owner_actor_id", table_name="fabric_graph_nodes")
|
||||
op.drop_index("ix_fabric_graph_nodes_environment", table_name="fabric_graph_nodes")
|
||||
op.drop_index("ix_fabric_graph_nodes_subfabric_id", table_name="fabric_graph_nodes")
|
||||
op.drop_index("ix_fabric_graph_nodes_fabric_id", table_name="fabric_graph_nodes")
|
||||
op.drop_index("ix_fabric_graph_nodes_evidence_review_state", table_name="fabric_graph_nodes")
|
||||
for column in (
|
||||
"profit_center_id",
|
||||
"cost_center_id",
|
||||
"ownership_resolution",
|
||||
"owner_role",
|
||||
"owner_actor_id",
|
||||
"deployment_scenario_id",
|
||||
"environment",
|
||||
"subfabric_id",
|
||||
"fabric_id",
|
||||
"netkingdom_id",
|
||||
"evidence_confidence",
|
||||
"evidence_review_state",
|
||||
):
|
||||
op.drop_column("fabric_graph_nodes", column)
|
||||
|
||||
op.drop_index("ix_fabric_graph_imports_schema_version", table_name="fabric_graph_imports")
|
||||
for column in (
|
||||
"compatibility",
|
||||
"unresolved_count",
|
||||
"fabric_count",
|
||||
"actor_count",
|
||||
"king_actor_id",
|
||||
"netkingdom_id",
|
||||
"schema_version",
|
||||
):
|
||||
op.drop_column("fabric_graph_imports", column)
|
||||
@@ -1058,6 +1058,193 @@ def _fabric_graph_export(generated_at="2026-05-23T12:00:00Z", extra_node=False):
|
||||
}
|
||||
|
||||
|
||||
def _financial_fabric_graph_export(generated_at="2026-05-24T00:00:00Z"):
|
||||
return {
|
||||
"apiVersion": "railiance.fabric/v1alpha2",
|
||||
"kind": "FabricGraphExport",
|
||||
"schema_version": "financial-fabric-v1",
|
||||
"generated_at": generated_at,
|
||||
"source": {
|
||||
"producer": "railiance-fabric",
|
||||
"registry": "registry",
|
||||
"commit": "financial-example",
|
||||
"generation_reason": "operator_refresh",
|
||||
},
|
||||
"compatibility": {
|
||||
"legacy_v1alpha1_supported": True,
|
||||
"breaking_reset": False,
|
||||
},
|
||||
"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 Primary 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",
|
||||
"parent_fabric_id": None,
|
||||
"status": "active",
|
||||
"boundary": {"boundary_type": "fabric"},
|
||||
"evidence_refs": [],
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"boundary": {"boundary_type": "subfabric"},
|
||||
"evidence_refs": [],
|
||||
},
|
||||
],
|
||||
"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",
|
||||
"deployment_scenario_id": None,
|
||||
},
|
||||
"ownership": {
|
||||
"owner_actor_id": "actor.railiance.primary-lord",
|
||||
"owner_role": "lord",
|
||||
"resolution": "inherited",
|
||||
"inherited_from": "fabric.railiance.primary",
|
||||
"supporting_actor_ids": [],
|
||||
},
|
||||
"accounting": {
|
||||
"cost_center_id": "cc.platform.shared",
|
||||
"allocation_model": "direct",
|
||||
},
|
||||
"evidence": {
|
||||
"state": "declared",
|
||||
"review_state": "accepted",
|
||||
"confidence": 0.9,
|
||||
"refs": [],
|
||||
},
|
||||
"canon_category": "endpoint",
|
||||
"canon_anchor": "model/network",
|
||||
"mapping_fit": "partial",
|
||||
"evidence_state": "declared",
|
||||
"attributes": {},
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"deployment_scenario_id": None,
|
||||
},
|
||||
"ownership": {
|
||||
"owner_actor_id": "actor.coulomb.tenant",
|
||||
"owner_role": "tenant",
|
||||
"resolution": "explicit",
|
||||
"supporting_actor_ids": [],
|
||||
},
|
||||
"accounting": {
|
||||
"cost_center_id": "cc.coulomb.automation",
|
||||
"allocation_model": "direct",
|
||||
},
|
||||
"evidence": {
|
||||
"state": "declared",
|
||||
"review_state": "accepted",
|
||||
"confidence": 0.8,
|
||||
"refs": [],
|
||||
},
|
||||
"attributes": {},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "utility:state-hub-http:coulomb-client",
|
||||
"from": "state-hub.http",
|
||||
"to": "coulomb.automation-client",
|
||||
"type": "provides_utility_to",
|
||||
"relationship_category": "utility",
|
||||
"canonical_type": "depends_on",
|
||||
"canon_anchor": "model/landscape",
|
||||
"mapping_fit": "partial",
|
||||
"display_only": False,
|
||||
"evidence_state": "declared",
|
||||
"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",
|
||||
},
|
||||
"boundary": {
|
||||
"crosses_fabric_boundary": False,
|
||||
"crosses_subfabric_boundary": True,
|
||||
},
|
||||
"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",
|
||||
},
|
||||
"evidence": {
|
||||
"state": "declared",
|
||||
"review_state": "accepted",
|
||||
"confidence": 0.8,
|
||||
"refs": [],
|
||||
},
|
||||
"attributes": {},
|
||||
}
|
||||
],
|
||||
"unresolved": [],
|
||||
}
|
||||
|
||||
|
||||
class TestFabricGraphReadModel:
|
||||
async def test_validation_failure_records_failed_import_without_read_model_rows(self, client):
|
||||
payload = _fabric_graph_export()
|
||||
@@ -1163,3 +1350,92 @@ class TestFabricGraphReadModel:
|
||||
assert r.json()["status"] == "ready"
|
||||
r = await client.get(f"/tasks/{task['id']}")
|
||||
assert r.json()["status"] == "todo"
|
||||
|
||||
async def test_financial_vnext_ingest_materializes_ownership_utility_and_summary(self, client):
|
||||
r = await client.post("/fabric/graph-exports", json=_financial_fabric_graph_export())
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["import_run"]["api_version"] == "railiance.fabric/v1alpha2"
|
||||
assert body["import_run"]["schema_version"] == "financial-fabric-v1"
|
||||
assert body["import_run"]["netkingdom_id"] == "railiance.netkingdom"
|
||||
assert body["import_run"]["actor_count"] == 3
|
||||
assert body["import_run"]["fabric_count"] == 2
|
||||
|
||||
r = await client.post(
|
||||
"/fabric/graph-exports",
|
||||
json=_financial_fabric_graph_export(generated_at="2026-05-24T00:05:00Z"),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
second_body = r.json()
|
||||
assert second_body["created"] is False
|
||||
assert second_body["idempotent"] is True
|
||||
assert second_body["import_run"]["id"] == body["import_run"]["id"]
|
||||
|
||||
r = await client.get("/fabric/graph/nodes?owner_role=tenant")
|
||||
assert r.status_code == 200
|
||||
tenant_nodes = r.json()
|
||||
assert [node["graph_id"] for node in tenant_nodes] == ["coulomb.automation-client"]
|
||||
assert tenant_nodes[0]["subfabric_id"] == "subfabric.railiance.tenant.coulomb"
|
||||
assert tenant_nodes[0]["owner_actor_id"] == "actor.coulomb.tenant"
|
||||
assert tenant_nodes[0]["ownership_resolution"] == "explicit"
|
||||
assert tenant_nodes[0]["cost_center_id"] == "cc.coulomb.automation"
|
||||
|
||||
r = await client.get(
|
||||
"/fabric/graph/edges"
|
||||
"?relationship_category=utility"
|
||||
"&consumer_owner_actor_id=actor.coulomb.tenant"
|
||||
"&crosses_subfabric_boundary=true"
|
||||
)
|
||||
assert r.status_code == 200
|
||||
utility_edges = r.json()
|
||||
assert len(utility_edges) == 1
|
||||
assert utility_edges[0]["utility_type"] == "coordination_api"
|
||||
assert utility_edges[0]["utility_payment_schema_id"] == "payment.internal-tenant-access"
|
||||
assert utility_edges[0]["provider_profit_center_id"] == "pc.tenant-utilities"
|
||||
|
||||
r = await client.get("/fabric/graph/summary")
|
||||
assert r.status_code == 200
|
||||
summary = r.json()
|
||||
assert summary["schema_version"] == "financial-fabric-v1"
|
||||
assert summary["nodes_by_fabric"]["fabric.railiance.primary"] == 2
|
||||
assert summary["nodes_by_subfabric"]["subfabric.railiance.tenant.coulomb"] == 1
|
||||
assert summary["nodes_by_owner_role"]["lord"] == 1
|
||||
assert summary["nodes_by_owner_role"]["tenant"] == 1
|
||||
assert summary["edges_by_relationship_category"]["utility"] == 1
|
||||
assert summary["utility_edges_by_provider_owner"]["actor.railiance.primary-lord"] == 1
|
||||
assert summary["utility_edges_by_consumer_owner"]["actor.coulomb.tenant"] == 1
|
||||
assert summary["utility_edges_by_business_model"]["tenant_utility"] == 1
|
||||
assert summary["tenant_utilities_without_payment_schema"] == 0
|
||||
assert summary["unresolved_ownership_count"] == 0
|
||||
|
||||
async def test_financial_vnext_validation_rejects_unresolved_accepted_ownership(self, client):
|
||||
payload = _financial_fabric_graph_export()
|
||||
payload["nodes"][0]["ownership"]["resolution"] = "unresolved"
|
||||
|
||||
r = await client.post("/fabric/graph-exports", json=payload)
|
||||
|
||||
assert r.status_code == 422
|
||||
body = r.json()["detail"]
|
||||
assert body["validation_status"] == "invalid"
|
||||
assert "accepted nodes" in body["message"]
|
||||
|
||||
r = await client.get("/fabric/graph/nodes")
|
||||
assert r.status_code == 404
|
||||
|
||||
async def test_legacy_fabric_exports_remain_compatible_with_null_financial_fields(self, client):
|
||||
r = await client.post("/fabric/graph-exports", json=_fabric_graph_export())
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["import_run"]["schema_version"] is None
|
||||
|
||||
r = await client.get("/fabric/graph/nodes?repo=state-hub&canonical_category=service")
|
||||
assert r.status_code == 200
|
||||
node = r.json()[0]
|
||||
assert node["graph_id"] == "the-custodian.state-hub"
|
||||
assert node["fabric_id"] is None
|
||||
assert node["owner_actor_id"] is None
|
||||
|
||||
r = await client.get("/fabric/graph/summary")
|
||||
assert r.status_code == 200
|
||||
summary = r.json()
|
||||
assert summary["schema_version"] is None
|
||||
assert summary["nodes_by_fabric"] == {}
|
||||
|
||||
@@ -4,11 +4,11 @@ type: workplan
|
||||
title: "Financial Fabric Read Model Adaptation"
|
||||
domain: custodian
|
||||
repo: state-hub
|
||||
status: ready
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: custodian
|
||||
created: "2026-05-23"
|
||||
updated: "2026-05-23"
|
||||
updated: "2026-05-24"
|
||||
state_hub_workstream_id: "6811cf57-2896-43a1-bbb5-162e0ccfa8c5"
|
||||
---
|
||||
|
||||
@@ -51,7 +51,7 @@ contract developed by `RAIL-FAB-WP-0017` and populated by `RAIL-FAB-WP-0018`.
|
||||
|
||||
```task
|
||||
id: STATE-WP-0051-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "cf39a822-81ec-4834-9b95-b8013ccc1434"
|
||||
```
|
||||
@@ -75,11 +75,37 @@ Done when:
|
||||
- blocking questions are fed back to `railiance-fabric` if the export contract
|
||||
is incomplete.
|
||||
|
||||
Review update 2026-05-24:
|
||||
|
||||
- vNext export identity is `apiVersion: railiance.fabric/v1alpha2` with
|
||||
`schema_version: financial-fabric-v1`; legacy
|
||||
`railiance.fabric/v1alpha1` remains supported for `STATE-WP-0050`
|
||||
imports.
|
||||
- Top-level vNext sections are `compatibility`, `netkingdom`, `actors`,
|
||||
`fabrics`, `nodes`, `edges`, and `unresolved`; State Hub will preserve them
|
||||
in `graph_json` and materialize dashboard/query columns where useful.
|
||||
- Node materialization will add containment (`netkingdom_id`, `fabric_id`,
|
||||
`subfabric_id`, `environment`, `deployment_scenario_id`), ownership
|
||||
(`owner_actor_id`, `owner_role`, `ownership_resolution`), accounting
|
||||
(`cost_center_id`, `profit_center_id`), and evidence review metadata.
|
||||
- Edge materialization will add `relationship_category`, utility
|
||||
provider/consumer owner and fabric/subfabric context, boundary crossing
|
||||
flags, utility type/payment/business metadata, accounting attribution, and
|
||||
evidence review metadata.
|
||||
- Import validation should accept both export families. For vNext, accepted
|
||||
nodes must carry containment, ownership, and evidence; utility edges must
|
||||
carry provider, consumer, boundary, and utility objects. State Hub will
|
||||
preserve unresolved or ambiguous markers from Fabric instead of inventing
|
||||
ownership.
|
||||
- Backward compatibility behavior: existing `v1alpha1` payloads continue to
|
||||
ingest and query with new columns left null/defaulted. The follow-up
|
||||
migration is additive and does not reset existing imports.
|
||||
|
||||
## T02 - Extend Read-Model Storage
|
||||
|
||||
```task
|
||||
id: STATE-WP-0051-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "989387ff-5c70-4eb4-a418-fd61e2a664dd"
|
||||
```
|
||||
@@ -103,11 +129,25 @@ Done when:
|
||||
- old imports remain readable or are intentionally reset with documentation;
|
||||
- latest-import behavior still works.
|
||||
|
||||
Result:
|
||||
|
||||
- Added an additive Alembic migration for financial Fabric vNext read-model
|
||||
columns on imports, nodes, and edges.
|
||||
- Imports now store `schema_version`, netkingdom/king actor metadata, actor,
|
||||
fabric, and unresolved counts, and compatibility metadata while preserving
|
||||
the full raw `graph_json`.
|
||||
- Nodes now materialize containment, ownership, evidence review, and optional
|
||||
cost/profit center attribution. Edges now materialize relationship category,
|
||||
utility provider/consumer context, boundary crossing flags, utility payment
|
||||
metadata, evidence review state, and accounting attribution.
|
||||
- Legacy `railiance.fabric/v1alpha1` imports remain valid with vNext columns
|
||||
left null or defaulted.
|
||||
|
||||
## T03 - Update Ingest And Validation
|
||||
|
||||
```task
|
||||
id: STATE-WP-0051-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "97c40d3c-33dd-4848-b375-4302811c0319"
|
||||
```
|
||||
@@ -131,11 +171,24 @@ Done when:
|
||||
- malformed exports fail without partially mutating the read model;
|
||||
- repeated vNext imports remain idempotent.
|
||||
|
||||
Result:
|
||||
|
||||
- Extended ingest validation to accept both `railiance.fabric/v1alpha1` and
|
||||
`railiance.fabric/v1alpha2` / `financial-fabric-v1`.
|
||||
- vNext validation requires netkingdom, actors, fabrics, node containment,
|
||||
node ownership, node/edge evidence, and complete utility edge provider,
|
||||
consumer, boundary, and utility metadata.
|
||||
- Accepted nodes with unresolved or ambiguous ownership are rejected; candidate
|
||||
or unresolved markers from Fabric are preserved instead of invented by State
|
||||
Hub.
|
||||
- Existing invalid-import behavior still records a failed import/progress row
|
||||
without writing graph nodes or edges.
|
||||
|
||||
## T04 - Add Query Surfaces And Views
|
||||
|
||||
```task
|
||||
id: STATE-WP-0051-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "64b0d9d2-bcfc-498f-b9b5-cbbcd3c26ead"
|
||||
```
|
||||
@@ -163,11 +216,22 @@ Done when:
|
||||
- no query mutates State Hub workstreams, tasks, messages, decisions, or
|
||||
progress rows.
|
||||
|
||||
Result:
|
||||
|
||||
- Extended `/fabric/graph/nodes` filters for fabric, subfabric, owner actor,
|
||||
owner role, ownership resolution, cost/profit center, evidence review state,
|
||||
and unresolved ownership.
|
||||
- Extended `/fabric/graph/edges` filters for relationship category, utility
|
||||
provider/consumer owners and boundaries, fabric/subfabric crossing flags,
|
||||
utility type/business/payment metadata, cost/profit attribution, evidence
|
||||
review state, and missing payment schema.
|
||||
- Kept existing endpoints read-only and backward compatible.
|
||||
|
||||
## T05 - Add Dashboard/Operator Readiness
|
||||
|
||||
```task
|
||||
id: STATE-WP-0051-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "852ac7b6-9296-4900-a34d-3e8ed2983237"
|
||||
```
|
||||
@@ -191,11 +255,23 @@ Done when:
|
||||
- the UI or documented API flow makes it clear that State Hub is displaying a
|
||||
Fabric read model, not authoring topology.
|
||||
|
||||
Result:
|
||||
|
||||
- Extended `/fabric/graph/summary` with dashboard-ready financial counters:
|
||||
schema/netkingdom metadata, actor/fabric/unresolved counts, nodes by
|
||||
fabric/subfabric/owner/role/resolution, utility edges by provider/consumer
|
||||
owner and business model, tenant utilities missing payment schema, nodes
|
||||
without accounting attribution, unresolved ownership, and unresolved
|
||||
accounting counts.
|
||||
- Updated `docs/fabric-graph-read-model.md` with vNext refresh and query
|
||||
examples and the source-of-truth warning that State Hub does not synthesize
|
||||
Fabric ownership.
|
||||
|
||||
## T06 - Verify Against A Rebuilt Railiance Baseline
|
||||
|
||||
```task
|
||||
id: STATE-WP-0051-T06
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "6bee7960-4714-4278-80fc-f8e32ec203bc"
|
||||
```
|
||||
@@ -219,6 +295,17 @@ Done when:
|
||||
- tests cover the new fields and compatibility behavior;
|
||||
- operator docs explain how to refresh after a Fabric rebuild.
|
||||
|
||||
Result:
|
||||
|
||||
- Added regression coverage for financial vNext ingest/materialization,
|
||||
summary counters, utility edge queries, invalid accepted-node ownership, and
|
||||
legacy v1alpha1 compatibility.
|
||||
- Verified the generated local `railiance-fabric export --format financial`
|
||||
payload validates in State Hub as `railiance.fabric/v1alpha2`,
|
||||
`financial-fabric-v1` with 2 actors, 1 fabric, 49 nodes, 58 edges, and 0
|
||||
unresolved gaps.
|
||||
- Full State Hub test suite passed: 332 tests.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- State Hub imports the vNext Fabric graph export as a read model only.
|
||||
@@ -228,4 +315,3 @@ Done when:
|
||||
- State Hub does not redefine Fabric boundaries or invent ownership.
|
||||
- Regression tests cover ingest, validation, idempotency, latest import, and
|
||||
read-only query behavior.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user