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

View File

@@ -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

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

View File

@@ -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"] == {}

View File

@@ -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.