diff --git a/README.md b/README.md
index 744ad79..d4b0c32 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
SkyData Studio is the post-ingestion data engineering application in the Sky ecosystem. It begins where SkyCommand's ingestion responsibility ends and prepares trusted data for analytical consumption by SkyWeb Analytics, Power BI, and future client-facing applications.
-**Current status:** Phases 0 through 7 are complete. Phase 7 closed with TRUSTED dbt quality evidence, a COMPLIANT 5/5 consumer gate, durable incident reconciliation, and a 30-day/99% quality SLO reporting MEETING at 100% observed compliance; the final Phase 7.4 validation passed 72 tests. **Phase 8.1 is now in progress:** Studio metadata mappings, dbt dependencies, semantic models, and governed metrics are stitched into one federated lineage graph with transitive downstream impact analysis.
+**Current status:** Phases 0 through 7 and Phase 8.1 are complete. Phase 8.1 closed with a READY 11-node/10-edge federated graph, a DFF impact radius reaching all 10 downstream nodes, and final validation passing 76 tests. **Phase 8.2 is now in progress:** source-to-target field mappings, dbt column annotations, and governed metric expressions are stitched into a field-level impact graph without creating a second lineage authority.
---
@@ -226,21 +226,27 @@ The first clean proof should show `MEETING`, 100% observed compliance, a 99% tar
---
-## Phase 8.1 quick start
+## Phase 8.2 quick start
-Phase 8.1 composes a read-only lineage graph from existing authorities rather than creating another lineage metadata store. The first proof joins the READY `DFF → FED_FUNDS_RATE_MART` Studio mapping to the dbt source/model DAG and then continues through the semantic model into all governed metrics.
+Phase 8.2 preserves the Phase 8.1 asset graph and adds field-level lineage without asking the Studio API to guess arbitrary SQL. The two registered Studio field mappings remain the source-to-target authority, while dbt model columns declare their upstream inputs beside the model definitions in `schema.yml`; `dbt build` carries those annotations into `manifest.json`. Governed metric expressions then bind mart fields to business measures.
-Start Studio PostgreSQL and ensure the current dbt artifacts exist, then inspect the graph:
+Regenerate the dbt artifacts after overlaying the Phase 8.2 model annotations:
+
+```powershell
+.\scripts\dbt.ps1 build
+```
+
+Inspect the field graph:
```powershell
Invoke-RestMethod `
- "http://localhost:8100/api/v1/lineage/summary" |
+ "http://localhost:8100/api/v1/lineage/fields/summary" |
ConvertTo-Json -Depth 12
```
-The current Federal Funds Rate proof should report one metadata mapping, 3 dbt business models, 1 semantic model, 4 metrics, 11 total nodes, and 10 directed edges. The default impact radius begins at `DFF` and should reach 10 downstream nodes including all 3 dbt models, the semantic model, and all 4 governed metrics.
+The Federal Funds Rate proof should report 2 Studio field mappings, 18 annotated dbt business-model columns, 4 metric bindings, 28 field/metric nodes, and 27 directed edges. Selecting `DFF.value` should reach 15 downstream nodes and exactly the average, minimum, and maximum Federal Funds Rate metrics while leaving the observation-count metric outside that radius.
-Open **Quality & Lineage → Lineage** and select any node to recompute its transitive downstream radius. You can query the same impact directly with `GET /api/v1/lineage/impact?nodeId=...`. Phase 8.1 remains a federated read model: field-level lineage, quality overlays, and report/consumer impact stay in later Phase 8 slices.
+Open **Quality & Lineage → Lineage** to inspect both the Phase 8.1 asset graph and the Phase 8.2 field graph. Field impact is also available through `GET /api/v1/lineage/fields/impact?fieldId=...`. The field graph remains a read model: Studio-owned field mappings, dbt-owned column annotations, and dbt-owned metric expressions stay at their existing authorities.
## Validation contract
@@ -496,4 +502,4 @@ The repository currently includes:
- a Phase 7.1 artifact-backed Data Quality surface that joins dbt test definitions to latest run outcomes and computes a consumer-facing trust posture;
- backend/frontend validation, repository map, and compact handoff tooling.
-The active implementation target is **Phase 8.1 — Cross-Layer Lineage Graph and Impact Radius Foundation**. Phases 0 through 7 are complete: the Studio now has governed ingestion handoffs, metadata and mappings, replay-safe pipelines, Airflow orchestration, dbt models and semantics, quality gates, durable incidents, and observation-backed reliability history. Phase 8.1 federates those existing dependency authorities into one directed graph so downstream impact can be explained without creating a second lineage store.
+The active implementation target is **Phase 8.2 — Field-Level Lineage and Column Impact Foundation**. Phases 0 through 7 are complete and Phase 8.1 established the federated asset/model/semantic/metric graph. Phase 8.2 carries the same ownership model down to individual fields so schema-change impact can be traced from DFF source columns through Studio mappings, dbt derivations, and governed metrics.
diff --git a/apps/api/skydata_studio/api/routes/lineage.py b/apps/api/skydata_studio/api/routes/lineage.py
index cb7d67e..d35488a 100644
--- a/apps/api/skydata_studio/api/routes/lineage.py
+++ b/apps/api/skydata_studio/api/routes/lineage.py
@@ -4,8 +4,18 @@
from sqlalchemy.exc import SQLAlchemyError
from skydata_studio.db.session import SessionDependency
-from skydata_studio.schemas.lineage import LineageImpactSummary, LineageSummary
-from skydata_studio.services.lineage import lineage_impact, lineage_summary
+from skydata_studio.schemas.lineage import (
+ FieldLineageImpactSummary,
+ FieldLineageSummary,
+ LineageImpactSummary,
+ LineageSummary,
+)
+from skydata_studio.services.lineage import (
+ field_lineage_impact,
+ field_lineage_summary,
+ lineage_impact,
+ lineage_summary,
+)
router = APIRouter()
@@ -40,3 +50,25 @@ def lineage_node_impact(
return lineage_impact(session, node_id)
except (SQLAlchemyError, OSError, ValueError, TypeError) as error:
raise _unavailable(error) from error
+
+
+@router.get("/fields/summary", response_model=FieldLineageSummary)
+def field_lineage_graph(
+ session: SessionDependency,
+ focus_field_id: Annotated[str | None, Query(alias="focusFieldId")] = None,
+) -> FieldLineageSummary:
+ try:
+ return field_lineage_summary(session, focus_field_id=focus_field_id)
+ except (SQLAlchemyError, OSError, ValueError, TypeError) as error:
+ raise _unavailable(error) from error
+
+
+@router.get("/fields/impact", response_model=FieldLineageImpactSummary)
+def field_lineage_field_impact(
+ session: SessionDependency,
+ field_id: Annotated[str, Query(alias="fieldId", min_length=1)],
+) -> FieldLineageImpactSummary:
+ try:
+ return field_lineage_impact(session, field_id)
+ except (SQLAlchemyError, OSError, ValueError, TypeError) as error:
+ raise _unavailable(error) from error
diff --git a/apps/api/skydata_studio/api/routes/platform.py b/apps/api/skydata_studio/api/routes/platform.py
index 39a4ccb..69d0648 100644
--- a/apps/api/skydata_studio/api/routes/platform.py
+++ b/apps/api/skydata_studio/api/routes/platform.py
@@ -60,7 +60,7 @@
code="LINEAGE_IMPACT",
name="Lineage and Impact Analysis",
description=(
- "Stitches metadata mappings, dbt dependencies, semantic models, "
+ "Stitches metadata mappings, dbt dependencies, field lineage, semantic models, "
"and metrics into one impact graph."
),
status="FOUNDATION",
@@ -96,7 +96,7 @@ def platform_summary() -> PlatformSummary:
product="SkyData Studio",
subtitle="Data Engineering Workbench",
theme="Aurora Foundry",
- current_phase="Phase 8.1 — Cross-Layer Lineage Graph and Impact Radius Foundation",
+ current_phase="Phase 8.2 — Field-Level Lineage and Column Impact Foundation",
boundary=(
"SkyData Studio starts after SkyCommand ingestion and publishes governed "
"analytical products for SkyWeb Analytics, Power BI, and future consumers."
diff --git a/apps/api/skydata_studio/schemas/dbt.py b/apps/api/skydata_studio/schemas/dbt.py
index 377de28..2a86cc2 100644
--- a/apps/api/skydata_studio/schemas/dbt.py
+++ b/apps/api/skydata_studio/schemas/dbt.py
@@ -32,6 +32,7 @@ class DbtTransformationSummary(BaseModel):
class DbtModelColumnSummary(BaseModel):
name: str
description: str | None = None
+ lineage_inputs: list[str] = Field(default_factory=list)
class DbtModelDependencySummary(BaseModel):
diff --git a/apps/api/skydata_studio/schemas/lineage.py b/apps/api/skydata_studio/schemas/lineage.py
index 989e5aa..9daedb4 100644
--- a/apps/api/skydata_studio/schemas/lineage.py
+++ b/apps/api/skydata_studio/schemas/lineage.py
@@ -74,3 +74,66 @@ class LineageSummary(BaseModel):
nodes: list[LineageNode]
edges: list[LineageEdge]
default_impact: LineageImpactSummary
+
+
+FieldLineageNodeType = Literal[
+ "SOURCE_FIELD",
+ "CURATED_FIELD",
+ "DBT_SOURCE_FIELD",
+ "DBT_MODEL_FIELD",
+ "METRIC",
+]
+FieldLineageEdgeType = Literal[
+ "FIELD_MAPPING",
+ "PUBLISHES_AS",
+ "DERIVES",
+ "FEEDS_METRIC",
+]
+
+
+class FieldLineageNode(BaseModel):
+ id: str
+ label: str
+ field_name: str
+ node_type: FieldLineageNodeType
+ layer: LineageLayer
+ system: str
+ relation: str | None = None
+ parent_node_id: str | None = None
+ parent_label: str | None = None
+ description: str | None = None
+ status: LineageStatus = "UNKNOWN"
+ metadata: dict[str, str | int | float | bool | None] = Field(default_factory=dict)
+
+
+class FieldLineageEdge(BaseModel):
+ id: str
+ upstream_id: str
+ downstream_id: str
+ edge_type: FieldLineageEdgeType
+ label: str
+
+
+class FieldLineageImpactSummary(BaseModel):
+ selected_field_id: str | None = None
+ selected_field_label: str | None = None
+ downstream_node_count: int = Field(ge=0)
+ affected_field_count: int = Field(ge=0)
+ affected_metric_count: int = Field(ge=0)
+ affected_relations: list[str]
+ affected_layers: list[str]
+ nodes: list[FieldLineageNode]
+
+
+class FieldLineageSummary(BaseModel):
+ project_name: str = "skydata_studio"
+ phase: str = "Phase 8.2 — Field-Level Lineage and Column Impact Foundation"
+ artifact_status: Literal["READY", "PARTIAL", "MISSING"]
+ field_mapping_count: int = Field(ge=0)
+ dbt_annotated_column_count: int = Field(ge=0)
+ metric_binding_count: int = Field(ge=0)
+ node_count: int = Field(ge=0)
+ edge_count: int = Field(ge=0)
+ nodes: list[FieldLineageNode]
+ edges: list[FieldLineageEdge]
+ default_impact: FieldLineageImpactSummary
diff --git a/apps/api/skydata_studio/services/dbt_transformations.py b/apps/api/skydata_studio/services/dbt_transformations.py
index d1d36bc..9033b2c 100644
--- a/apps/api/skydata_studio/services/dbt_transformations.py
+++ b/apps/api/skydata_studio/services/dbt_transformations.py
@@ -261,12 +261,26 @@ def dbt_model_catalogue(target_dir: Path | None = None) -> DbtModelCatalogueSumm
if isinstance(columns_payload, dict):
for column_name, column_payload in columns_payload.items():
description = None
+ lineage_inputs: list[str] = []
if isinstance(column_payload, dict):
raw_description = column_payload.get("description")
if isinstance(raw_description, str) and raw_description.strip():
description = raw_description
+ raw_meta = column_payload.get("meta")
+ if isinstance(raw_meta, dict):
+ raw_lineage_inputs = raw_meta.get("lineage_inputs")
+ if isinstance(raw_lineage_inputs, list):
+ lineage_inputs = [
+ str(item)
+ for item in raw_lineage_inputs
+ if isinstance(item, str) and item.strip()
+ ]
columns.append(
- DbtModelColumnSummary(name=str(column_name), description=description)
+ DbtModelColumnSummary(
+ name=str(column_name),
+ description=description,
+ lineage_inputs=lineage_inputs,
+ )
)
test_count = 0
@@ -538,12 +552,31 @@ def dbt_semantic_layer(target_dir: Path | None = None) -> DbtSemanticLayerSummar
metric_type = str(metric.get("type") or "simple").upper()
if metric_type not in {"SIMPLE", "RATIO", "CUMULATIVE", "DERIVED", "CONVERSION"}:
metric_type = "SIMPLE"
- aggregation_value = metric.get("agg", measure.get("agg"))
- expression_value = metric.get("expr", measure.get("expr"))
+ raw_type_params = metric.get("type_params")
+ type_params = raw_type_params if isinstance(raw_type_params, dict) else {}
+ raw_aggregation_params = type_params.get("metric_aggregation_params")
+ aggregation_params = (
+ raw_aggregation_params if isinstance(raw_aggregation_params, dict) else {}
+ )
+ aggregation_value = metric.get(
+ "agg",
+ measure.get("agg", aggregation_params.get("agg")),
+ )
+ expression_value = metric.get(
+ "expr",
+ measure.get("expr", type_params.get("expr")),
+ )
time_dimension_value = metric.get(
"agg_time_dimension",
- measure.get("agg_time_dimension", default_time_dimension),
+ measure.get(
+ "agg_time_dimension",
+ aggregation_params.get("agg_time_dimension", default_time_dimension),
+ ),
)
+ if semantic_name is None:
+ raw_semantic_name = aggregation_params.get("semantic_model")
+ if isinstance(raw_semantic_name, str) and raw_semantic_name:
+ semantic_name = raw_semantic_name
aggregation = (
str(aggregation_value) if isinstance(aggregation_value, str) else None
)
diff --git a/apps/api/skydata_studio/services/lineage.py b/apps/api/skydata_studio/services/lineage.py
index 25bffe3..3507131 100644
--- a/apps/api/skydata_studio/services/lineage.py
+++ b/apps/api/skydata_studio/services/lineage.py
@@ -7,13 +7,20 @@
from skydata_studio.schemas.dbt import DbtModelCatalogueSummary, DbtSemanticLayerSummary
from skydata_studio.schemas.lineage import (
+ FieldLineageEdge,
+ FieldLineageImpactSummary,
+ FieldLineageNode,
+ FieldLineageSummary,
LineageEdge,
LineageImpactSummary,
LineageNode,
LineageSummary,
)
from skydata_studio.services.dbt_transformations import dbt_model_catalogue, dbt_semantic_layer
-from skydata_studio.services.metadata_registry import list_metadata_mappings
+from skydata_studio.services.metadata_registry import (
+ get_metadata_mapping,
+ list_metadata_mappings,
+)
def _canonical_name(value: str) -> str:
@@ -352,3 +359,369 @@ def lineage_summary(
def lineage_impact(session: Session, node_id: str) -> LineageImpactSummary:
summary = lineage_summary(session, focus_node_id=node_id)
return summary.default_impact
+
+
+
+def _field_node_id(parent_id: str, field_name: str) -> str:
+ return f"field:{parent_id}:{field_name.strip().lower()}"
+
+
+def _field_impact_from_graph(
+ nodes: list[FieldLineageNode],
+ edges: list[FieldLineageEdge],
+ selected_field_id: str | None,
+) -> FieldLineageImpactSummary:
+ node_by_id = {node.id: node for node in nodes}
+ if selected_field_id not in node_by_id:
+ selected_field_id = None
+
+ if selected_field_id is None:
+ preferred = next(
+ (node.id for node in nodes if node.node_type == "SOURCE_FIELD"),
+ None,
+ )
+ selected_field_id = preferred or (nodes[0].id if nodes else None)
+
+ if selected_field_id is None:
+ return FieldLineageImpactSummary(
+ downstream_node_count=0,
+ affected_field_count=0,
+ affected_metric_count=0,
+ affected_relations=[],
+ affected_layers=[],
+ nodes=[],
+ )
+
+ adjacency: dict[str, list[str]] = {}
+ for edge in edges:
+ adjacency.setdefault(edge.upstream_id, []).append(edge.downstream_id)
+
+ visited: set[str] = set()
+ queue: deque[str] = deque(adjacency.get(selected_field_id, []))
+ while queue:
+ node_id = queue.popleft()
+ if node_id in visited:
+ continue
+ visited.add(node_id)
+ queue.extend(adjacency.get(node_id, []))
+
+ impacted = [node for node in nodes if node.id in visited]
+ return FieldLineageImpactSummary(
+ selected_field_id=selected_field_id,
+ selected_field_label=node_by_id[selected_field_id].label,
+ downstream_node_count=len(impacted),
+ affected_field_count=sum(node.node_type != "METRIC" for node in impacted),
+ affected_metric_count=sum(node.node_type == "METRIC" for node in impacted),
+ affected_relations=sorted(
+ {node.relation for node in impacted if node.relation is not None}
+ ),
+ affected_layers=sorted({str(node.layer) for node in impacted}),
+ nodes=impacted,
+ )
+
+
+def _parse_lineage_input(reference: str) -> tuple[str, str, str] | None:
+ kind, separator, remainder = reference.partition(":")
+ if not separator or kind not in {"source", "model"}:
+ return None
+ parent, dot, field_name = remainder.rpartition(".")
+ if not dot or not parent or not field_name:
+ return None
+ return kind, parent, field_name
+
+
+def compose_field_lineage_summary(
+ session: Session,
+ *,
+ catalogue: DbtModelCatalogueSummary,
+ semantic: DbtSemanticLayerSummary,
+ focus_field_id: str | None = None,
+) -> FieldLineageSummary:
+ nodes: list[FieldLineageNode] = []
+ edges: list[FieldLineageEdge] = []
+ node_ids: set[str] = set()
+ edge_ids: set[str] = set()
+
+ def add_node(node: FieldLineageNode) -> None:
+ if node.id in node_ids:
+ return
+ node_ids.add(node.id)
+ nodes.append(node)
+
+ def add_edge(edge: FieldLineageEdge) -> None:
+ if edge.id in edge_ids:
+ return
+ edge_ids.add(edge.id)
+ edges.append(edge)
+
+ mappings = list_metadata_mappings(session, status="READY", limit=500, offset=0)
+ target_fields_by_canonical: dict[tuple[str, str], str] = {}
+ field_mapping_count = 0
+
+ for mapping_item in mappings.items:
+ mapping = get_metadata_mapping(session, mapping_item.id)
+ source_parent_id = _metadata_node_id(mapping.source_asset.id)
+ target_parent_id = _metadata_node_id(mapping.target_asset.id)
+ target_canonical = _canonical_name(mapping.target_asset.code)
+
+ for field_mapping in mapping.field_mappings:
+ if field_mapping.source_field_code is None:
+ continue
+ field_mapping_count += 1
+ source_field = field_mapping.source_field_code.lower()
+ target_field = field_mapping.target_field_code.lower()
+ source_id = _field_node_id(source_parent_id, source_field)
+ target_id = _field_node_id(target_parent_id, target_field)
+
+ add_node(
+ FieldLineageNode(
+ id=source_id,
+ label=f"{mapping.source_asset.code}.{source_field}",
+ field_name=source_field,
+ node_type="SOURCE_FIELD",
+ layer="RAW",
+ system=mapping.source_asset.system_code,
+ relation=mapping.source_asset.code,
+ parent_node_id=source_parent_id,
+ parent_label=mapping.source_asset.code,
+ status="READY",
+ metadata={
+ "mapping_code": mapping.code,
+ "transformation_type": field_mapping.transformation_type,
+ },
+ )
+ )
+ add_node(
+ FieldLineageNode(
+ id=target_id,
+ label=f"{mapping.target_asset.code}.{target_field}",
+ field_name=target_field,
+ node_type="CURATED_FIELD",
+ layer="MART",
+ system=mapping.target_asset.system_code,
+ relation=mapping.target_asset.code,
+ parent_node_id=target_parent_id,
+ parent_label=mapping.target_asset.code,
+ description=field_mapping.description,
+ status="READY",
+ metadata={
+ "target_data_type": field_mapping.target_data_type,
+ "transformation_type": field_mapping.transformation_type,
+ },
+ )
+ )
+ add_edge(
+ FieldLineageEdge(
+ id=f"field-mapping:{field_mapping.id}",
+ upstream_id=source_id,
+ downstream_id=target_id,
+ edge_type="FIELD_MAPPING",
+ label=field_mapping.transformation_type,
+ )
+ )
+ target_fields_by_canonical[(target_canonical, target_field)] = target_id
+
+ source_field_ids: dict[tuple[str, str], str] = {}
+ source_unique_ids: dict[str, str] = {}
+ for model in catalogue.models:
+ for dependency in model.upstream:
+ if dependency.resource_type == "SOURCE":
+ source_unique_ids.setdefault(dependency.name, dependency.unique_id)
+
+ for source_name, unique_id in source_unique_ids.items():
+ canonical = _canonical_name(source_name)
+ for (
+ target_canonical,
+ field_name,
+ ), published_upstream_id in target_fields_by_canonical.items():
+ if target_canonical != canonical:
+ continue
+ parent_id = _dbt_node_id(unique_id)
+ node_id = _field_node_id(parent_id, field_name)
+ source_field_ids[(source_name, field_name)] = node_id
+ add_node(
+ FieldLineageNode(
+ id=node_id,
+ label=f"{source_name}.{field_name}",
+ field_name=field_name,
+ node_type="DBT_SOURCE_FIELD",
+ layer="SOURCE",
+ system="dbt",
+ relation=f"mart.{source_name}",
+ parent_node_id=parent_id,
+ parent_label=source_name,
+ status="READY" if catalogue.artifact_status == "READY" else "MISSING",
+ metadata={"unique_id": unique_id},
+ )
+ )
+ add_edge(
+ FieldLineageEdge(
+ id=f"field-publish:{published_upstream_id}:{node_id}",
+ upstream_id=published_upstream_id,
+ downstream_id=node_id,
+ edge_type="PUBLISHES_AS",
+ label="curated field → dbt source field",
+ )
+ )
+
+ model_by_name = {model.name: model for model in catalogue.models}
+ model_field_ids: dict[tuple[str, str], str] = {}
+ dbt_annotated_column_count = 0
+
+ for model in catalogue.models:
+ parent_id = _dbt_node_id(model.unique_id)
+ for column in model.columns:
+ if not column.lineage_inputs:
+ continue
+ dbt_annotated_column_count += 1
+ field_name = column.name.lower()
+ node_id = _field_node_id(parent_id, field_name)
+ model_field_ids[(model.name, field_name)] = node_id
+ add_node(
+ FieldLineageNode(
+ id=node_id,
+ label=f"{model.name}.{field_name}",
+ field_name=field_name,
+ node_type="DBT_MODEL_FIELD",
+ layer=model.layer,
+ system="dbt",
+ relation=model.relation,
+ parent_node_id=parent_id,
+ parent_label=model.name,
+ description=column.description,
+ status="READY" if model.build_status == "READY" else "UNKNOWN",
+ metadata={
+ "materialization": model.materialization,
+ "lineage_inputs": len(column.lineage_inputs),
+ },
+ )
+ )
+
+ for model in catalogue.models:
+ for column in model.columns:
+ downstream_id = model_field_ids.get((model.name, column.name.lower()))
+ if downstream_id is None:
+ continue
+ for reference in column.lineage_inputs:
+ parsed = _parse_lineage_input(reference)
+ if parsed is None:
+ continue
+ kind, parent_name, field_name = parsed
+ lineage_upstream_id: str | None = None
+ if kind == "source":
+ source_name = parent_name.split(".")[-1]
+ lineage_upstream_id = source_field_ids.get(
+ (source_name, field_name.lower())
+ )
+ else:
+ upstream_model = model_by_name.get(parent_name)
+ if upstream_model is not None:
+ lineage_upstream_id = model_field_ids.get(
+ (upstream_model.name, field_name.lower())
+ )
+ if lineage_upstream_id is None:
+ continue
+ add_edge(
+ FieldLineageEdge(
+ id=f"field-derive:{lineage_upstream_id}:{downstream_id}",
+ upstream_id=lineage_upstream_id,
+ downstream_id=downstream_id,
+ edge_type="DERIVES",
+ label="dbt column lineage",
+ )
+ )
+
+ semantic_model_by_name = {model.name: model for model in semantic.semantic_models}
+ model_by_relation = {model.relation.lower(): model for model in catalogue.models}
+ metric_binding_count = 0
+ for metric in semantic.metrics:
+ semantic_model = (
+ semantic_model_by_name.get(metric.semantic_model)
+ if metric.semantic_model
+ else None
+ )
+ if semantic_model is None or semantic_model.relation is None or not metric.expression:
+ continue
+ mart_model = model_by_relation.get(semantic_model.relation.lower())
+ if mart_model is None:
+ continue
+ metric_upstream_id = model_field_ids.get(
+ (mart_model.name, metric.expression.lower())
+ )
+ if metric_upstream_id is None:
+ continue
+ metric_binding_count += 1
+ metric_parent_id = _metric_node_id(metric.unique_id)
+ metric_id = _field_node_id(metric_parent_id, metric.name)
+ add_node(
+ FieldLineageNode(
+ id=metric_id,
+ label=metric.label or metric.name,
+ field_name=metric.name,
+ node_type="METRIC",
+ layer="METRIC",
+ system="dbt semantic",
+ parent_node_id=metric_parent_id,
+ parent_label=metric.semantic_model,
+ description=metric.description,
+ status="READY" if semantic.artifact_status == "READY" else "MISSING",
+ metadata={
+ "expression": metric.expression,
+ "aggregation": metric.aggregation,
+ "time_dimension": metric.time_dimension,
+ },
+ )
+ )
+ add_edge(
+ FieldLineageEdge(
+ id=f"field-metric:{metric_upstream_id}:{metric_id}",
+ upstream_id=metric_upstream_id,
+ downstream_id=metric_id,
+ edge_type="FEEDS_METRIC",
+ label=f"{metric.aggregation or metric.metric_type}({metric.expression})",
+ )
+ )
+
+ artifact_status: Literal["READY", "PARTIAL", "MISSING"]
+ if (
+ catalogue.artifact_status == "READY"
+ and semantic.artifact_status == "READY"
+ and field_mapping_count > 0
+ and dbt_annotated_column_count > 0
+ ):
+ artifact_status = "READY"
+ elif nodes:
+ artifact_status = "PARTIAL"
+ else:
+ artifact_status = "MISSING"
+
+ default_impact = _field_impact_from_graph(nodes, edges, focus_field_id)
+ return FieldLineageSummary(
+ artifact_status=artifact_status,
+ field_mapping_count=field_mapping_count,
+ dbt_annotated_column_count=dbt_annotated_column_count,
+ metric_binding_count=metric_binding_count,
+ node_count=len(nodes),
+ edge_count=len(edges),
+ nodes=nodes,
+ edges=edges,
+ default_impact=default_impact,
+ )
+
+
+def field_lineage_summary(
+ session: Session,
+ *,
+ focus_field_id: str | None = None,
+) -> FieldLineageSummary:
+ return compose_field_lineage_summary(
+ session,
+ catalogue=dbt_model_catalogue(),
+ semantic=dbt_semantic_layer(),
+ focus_field_id=focus_field_id,
+ )
+
+
+def field_lineage_impact(session: Session, field_id: str) -> FieldLineageImpactSummary:
+ summary = field_lineage_summary(session, focus_field_id=field_id)
+ return summary.default_impact
diff --git a/apps/web/src/components/FieldLineagePanel.jsx b/apps/web/src/components/FieldLineagePanel.jsx
new file mode 100644
index 0000000..ffaf2d5
--- /dev/null
+++ b/apps/web/src/components/FieldLineagePanel.jsx
@@ -0,0 +1,220 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import StatusPill from './StatusPill.jsx';
+import { getJson } from '../services/api.js';
+
+const EMPTY_IMPACT = {
+ selected_field_id: null,
+ selected_field_label: null,
+ downstream_node_count: 0,
+ affected_field_count: 0,
+ affected_metric_count: 0,
+ affected_relations: [],
+ affected_layers: [],
+ nodes: [],
+};
+
+const EMPTY = {
+ artifact_status: 'MISSING',
+ field_mapping_count: 0,
+ dbt_annotated_column_count: 0,
+ metric_binding_count: 0,
+ node_count: 0,
+ edge_count: 0,
+ nodes: [],
+ edges: [],
+ default_impact: EMPTY_IMPACT,
+};
+
+function fieldOrder(node) {
+ if (node.node_type === 'SOURCE_FIELD') return 1;
+ if (node.node_type === 'CURATED_FIELD') return 2;
+ if (node.node_type === 'DBT_SOURCE_FIELD') return 3;
+ if (node.layer === 'STAGING') return 4;
+ if (node.layer === 'INTERMEDIATE') return 5;
+ if (node.layer === 'MART') return 6;
+ if (node.node_type === 'METRIC') return 7;
+ return 99;
+}
+
+function statusTone(status) {
+ if (status === 'READY') return 'READY';
+ if (status === 'PARTIAL') return 'WARNING';
+ return 'PLANNED';
+}
+
+function fieldKind(node) {
+ if (node.node_type === 'SOURCE_FIELD') return 'Source field';
+ if (node.node_type === 'CURATED_FIELD') return 'Curated field';
+ if (node.node_type === 'DBT_SOURCE_FIELD') return 'dbt source field';
+ if (node.node_type === 'DBT_MODEL_FIELD') return 'dbt model field';
+ return 'Governed metric';
+}
+
+function FieldLineagePanel() {
+ const [summary, setSummary] = useState(EMPTY);
+ const [impact, setImpact] = useState(EMPTY_IMPACT);
+ const [loading, setLoading] = useState(true);
+ const [impactLoading, setImpactLoading] = useState(false);
+ const [error, setError] = useState('');
+
+ const applySummary = useCallback((payload) => {
+ setSummary(payload);
+ setImpact(payload.default_impact || EMPTY_IMPACT);
+ }, []);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError('');
+ try {
+ applySummary(await getJson('/api/v1/lineage/fields/summary'));
+ } catch (requestError) {
+ setError(requestError.message);
+ } finally {
+ setLoading(false);
+ }
+ }, [applySummary]);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ getJson('/api/v1/lineage/fields/summary', { signal: controller.signal })
+ .then(applySummary)
+ .catch((requestError) => {
+ if (requestError.name !== 'AbortError') setError(requestError.message);
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setLoading(false);
+ });
+ return () => controller.abort();
+ }, [applySummary]);
+
+ const groups = useMemo(() => {
+ const grouped = new Map();
+ [...summary.nodes]
+ .sort((left, right) => (
+ fieldOrder(left) - fieldOrder(right) || left.label.localeCompare(right.label)
+ ))
+ .forEach((node) => {
+ const parent = node.parent_label || node.relation || node.layer;
+ const key = `${fieldOrder(node)}:${parent}`;
+ const current = grouped.get(key) || { key, label: parent, layer: node.layer, nodes: [] };
+ current.nodes.push(node);
+ grouped.set(key, current);
+ });
+ return [...grouped.values()];
+ }, [summary.nodes]);
+
+ const impactedIds = useMemo(
+ () => new Set((impact.nodes || []).map((node) => node.id)),
+ [impact.nodes],
+ );
+
+ async function selectField(node) {
+ setImpactLoading(true);
+ setError('');
+ try {
+ const path = `/api/v1/lineage/fields/impact?fieldId=${encodeURIComponent(node.id)}`;
+ setImpact(await getJson(path));
+ } catch (requestError) {
+ setError(requestError.message);
+ } finally {
+ setImpactLoading(false);
+ }
+ }
+
+ return (
+ <>
+
+ {impactLoading
+ ? 'Tracing column-level impact…'
+ : impact.downstream_node_count
+ ? `This field can affect ${impact.affected_layers.join(' → ')}.`
+ : 'This field has no downstream consumers in the current graph.'}
+ No downstream field impact for the selected field.DFF fields → curated columns → dbt derivations → governed metrics
+ .\scripts\dbt.ps1 build to regenerate the dbt manifest.
+ {impact.selected_field_label || 'Select a field'}
Dependent fields and metrics
- Stitch Studio metadata mappings, dbt dependency artifacts, semantic models, and governed - metrics into one read-only graph, then trace what sits downstream before a change lands. + Keep the federated asset graph from Phase 8.1, then trace field-level impact from registered + mappings through dbt derivations and into governed metrics before a schema change lands.
- Phase 8.1 is a federated read model. It does not copy dbt dependency metadata into Studio PostgreSQL or make lineage its own new authority. Field-level impact, quality overlays, and consumer/report lineage remain later Phase 8 slices. + Phase 8.2 remains a federated read model. Studio does not infer arbitrary SQL or persist a second lineage store; mapping fields, dbt column annotations, and metric expressions stay at their existing authorities. Quality overlays and consumer/report lineage remain later Phase 8 slices.
{summary.boundary}