diff --git a/api/models/fabric_graph.py b/api/models/fabric_graph.py index 384389f..850616c 100644 --- a/api/models/fabric_graph.py +++ b/api/models/fabric_graph.py @@ -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="{}") diff --git a/api/routers/fabric.py b/api/routers/fabric.py index 5e44e65..c389342 100644 --- a/api/routers/fabric.py +++ b/api/routers/fabric.py @@ -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) diff --git a/api/schemas/fabric_graph.py b/api/schemas/fabric_graph.py index 494065a..b1ea79e 100644 --- a/api/schemas/fabric_graph.py +++ b/api/schemas/fabric_graph.py @@ -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] diff --git a/api/services/fabric_graph.py b/api/services/fabric_graph.py index 3bdc617..d8a5f56 100644 --- a/api/services/fabric_graph.py +++ b/api/services/fabric_graph.py @@ -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): diff --git a/docs/fabric-graph-read-model.md b/docs/fabric-graph-read-model.md index 79ab8a9..15ddcec 100644 --- a/docs/fabric-graph-read-model.md +++ b/docs/fabric-graph-read-model.md @@ -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 diff --git a/migrations/versions/z3u4v5w6x7y8_financial_fabric_vnext.py b/migrations/versions/z3u4v5w6x7y8_financial_fabric_vnext.py new file mode 100644 index 0000000..495a436 --- /dev/null +++ b/migrations/versions/z3u4v5w6x7y8_financial_fabric_vnext.py @@ -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) diff --git a/tests/test_routers_core.py b/tests/test_routers_core.py index d6b4bbc..a7213fd 100644 --- a/tests/test_routers_core.py +++ b/tests/test_routers_core.py @@ -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"] == {} diff --git a/workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md b/workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md index 6f132e3..ef36505 100644 --- a/workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md +++ b/workplans/STATE-WP-0051-financial-fabric-read-model-adaptation.md @@ -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. -