diff --git a/README.md b/README.md
index 5a18401..cdc253a 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 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.
+**Current status:** Phases 0 through 8 are complete. Phase 8 closed with source-controlled analytics-consumer lineage resolving one declared report against all four governed Federal Funds Rate metrics, with a 5-node/4-edge consumer graph and metric-level downstream impact. **Phase 9.1 is now in progress:** analytical mart publication readiness composes physical relation evidence, dbt build state, source-to-mart freshness, quality policy, semantic resolution, and declared consumers into one explicit delivery gate.
---
@@ -406,8 +406,8 @@ The public roadmap is intentionally compact so GitHub visitors can see progress
| Phase 5 | β Complete | Apache Airflow 3 durable batch orchestration, REST API v2 integration, DAG/run/task observability, schedules, backfills, and ingestion-complete triggers |
| Phase 6 | β Complete | dbt transformation and modelling foundation with tested staging, intermediate, mart, and semantic layers |
| Phase 7 | β Complete | Data quality, reconciliation, blocking policies, incidents, SLOs, and observability |
-| Phase 8 | π In Progress | Asset/field/model/report lineage and downstream impact analysis |
-| Phase 9 | β³ Planned | Curated analytical marts, governed metrics, semantic delivery, and SkyWeb consumer contracts |
+| Phase 8 | β Complete | Asset/field/model/report lineage, trust/runtime overlays, and downstream impact analysis |
+| Phase 9 | π In Progress | Curated analytical marts, publication readiness, governed semantic delivery, and downstream consumer products |
| Phase 10 | β³ Planned | Power BI semantic models, refresh strategy, reporting inventory, and governed executive delivery |
| Phase 11 | β³ Planned | Security, RBAC, environment promotion, audit evidence, backup, recovery, and retention |
| Phase 12 | β³ Planned | AI-assisted mapping, SQL, tests, documentation, and explain-before-apply engineering workflows |
@@ -520,11 +520,11 @@ The repository currently includes:
- a completed Phase 8.2 field-level lineage graph from DFF source fields through dbt derivations to governed metrics;
- a completed Phase 8.3 trust overlay that projects dbt checks, quality-contract rules, and durable incidents onto exact lineage nodes;
- a completed Phase 8.4 runtime-lineage workbench that links the latest Airflow DAG/task execution to its replay-safe Studio pipeline run, structured steps, source asset, and curated target;
-- a Phase 8.5 analytics-consumer lineage surface that resolves source-controlled report dependencies against governed dbt semantic metrics without claiming downstream deployment;
+- a completed Phase 8.5 analytics-consumer lineage surface that resolves source-controlled report dependencies against governed dbt semantic metrics without claiming downstream deployment;
+- an active Phase 9.1 analytical-product gate that compares the curated source with its dbt mart and composes freshness, quality, semantic, and consumer evidence before publication;
- backend/frontend validation, repository map, and compact handoff tooling.
-The active implementation target is **Phase 8.5 β Analytics Consumer Lineage and Impact Closure**. Phases 0 through 7 are complete; Phase 8.1 established asset impact, Phase 8.2 added field impact, Phase 8.3 projected trust evidence, and Phase 8.4 joined Airflow/Studio runtime execution to the structural graph. Phase 8.5 now closes the lineage chain at source-controlled analytics consumers while keeping actual analytical delivery and Power BI deployment in their later roadmap phases.
-
+The active implementation target is **Phase 9.1 β Analytical Mart Publication Readiness and Freshness Gate**. Phases 0 through 8 are complete. Phase 9.1 converts the proven Federal Funds Rate mart into a governed analytical product whose publication state becomes READY only when physical source/mart evidence, dbt build status, freshness alignment, quality policy, semantic definitions, and declared consumers all resolve together.
## Phase 8.5 quick start
@@ -537,3 +537,29 @@ Invoke-RestMethod `
```
A complete proof reports one resolved consumer, four resolved metrics, zero unresolved dependencies, 5 nodes, and 4 `CONSUMED_BY` edges.
+
+Phase 8.5 closure evidence:
+
+- `GET /api/v1/lineage/consumers/summary` returned `READY` against `READY` semantic artifacts with 1/1 consumer contracts resolved, 4/4 governed metrics resolved, zero unresolved metrics, and a 5-node/4-edge consumer graph;
+- `average_federal_funds_rate` reported exactly one downstream consumer, `Federal Funds Rate Overview`, targeting Power BI with deployment status `DECLARED`;
+- the Lineage workbench rendered consumers 1/1, metrics 4/4, semantic READY, and the explicit declaration-only delivery boundary;
+- local code/test/build validation passed Ruff, mypy across 60 source files, 92 pytest tests, ESLint, and Vite production build;
+- `npm ci` also surfaced one high-severity dependency advisory. The feature phase is functionally closed, but dependency-security maintenance remains an explicit follow-up rather than being misreported as a clean audit.
+
+## Phase 9.1 quick start
+
+Phase 9.1 introduces the first source-controlled analytical-product contract at `contracts/analytics/products/fed_funds_rate_daily_product.v1.json`. Studio reads the curated source relation, the dbt mart, generated dbt artifacts, the Phase 7 quality contract, and the Phase 8.5 consumer declaration at request time. It does not execute dbt automatically.
+
+```powershell
+Invoke-RestMethod `
+ "http://localhost:8100/api/v1/analytics/products/summary" |
+ ConvertTo-Json -Depth 12
+```
+
+If ingestion has advanced `mart.fed_funds_rate` since the latest dbt build, the correct first result is `product_status=STALE` with the source row/date evidence ahead of `dbt_mart.fct_fed_funds_rate_daily`. Refresh the transformation explicitly:
+
+```powershell
+.\scripts\dbt.ps1 build
+```
+
+Then refresh the endpoint or **Analytics Delivery β Analytical Marts**. Publication becomes `READY` only after all six gates pass: physical relations, dbt build evidence, freshness alignment, quality contract, semantic product resolution, and declared-consumer resolution.
diff --git a/apps/api/skydata_studio/api/router.py b/apps/api/skydata_studio/api/router.py
index 5296f5a..d5c5e48 100644
--- a/apps/api/skydata_studio/api/router.py
+++ b/apps/api/skydata_studio/api/router.py
@@ -2,6 +2,7 @@
from skydata_studio.api.routes import (
airflow,
+ analytics,
contracts,
dbt,
health,
@@ -16,6 +17,7 @@
api_router = APIRouter()
api_router.include_router(health.router, prefix="/health", tags=["health"])
+api_router.include_router(analytics.router, prefix="/analytics", tags=["analytics-delivery"])
api_router.include_router(
airflow.router,
prefix="/integrations/airflow",
diff --git a/apps/api/skydata_studio/api/routes/analytics.py b/apps/api/skydata_studio/api/routes/analytics.py
new file mode 100644
index 0000000..fc27a5c
--- /dev/null
+++ b/apps/api/skydata_studio/api/routes/analytics.py
@@ -0,0 +1,21 @@
+from fastapi import APIRouter, HTTPException, status
+
+from skydata_studio.db.session import SessionDependency
+from skydata_studio.schemas.analytics import AnalyticsProductSummary
+from skydata_studio.services.analytics_products import analytics_product_summary
+
+router = APIRouter()
+
+
+@router.get("/products/summary", response_model=AnalyticsProductSummary)
+def product_summary(session: SessionDependency) -> AnalyticsProductSummary:
+ try:
+ return analytics_product_summary(session)
+ except (OSError, ValueError, TypeError) as error:
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail=(
+ "SkyData Studio could not compose analytical-product readiness. "
+ "Verify product contracts, dbt artifacts, and Studio PostgreSQL."
+ ),
+ ) from error
diff --git a/apps/api/skydata_studio/api/routes/platform.py b/apps/api/skydata_studio/api/routes/platform.py
index 8812317..d62100a 100644
--- a/apps/api/skydata_studio/api/routes/platform.py
+++ b/apps/api/skydata_studio/api/routes/platform.py
@@ -63,14 +63,14 @@
"Stitches mappings, dbt and field dependencies, semantic delivery, "
"quality controls, incidents, and runtime execution into one impact graph."
),
- status="FOUNDATION",
+ status="READY",
phase=8,
),
Capability(
code="ANALYTICS_DELIVERY",
name="Analytics Delivery",
description="Publishes curated products to SkyWeb Analytics and Power BI.",
- status="PLANNED",
+ status="FOUNDATION",
phase=9,
),
]
@@ -84,8 +84,8 @@
RoadmapPhase(number=5, name="Apache Airflow Integration", status="COMPLETE"),
RoadmapPhase(number=6, name="dbt Transformation and Modelling", status="COMPLETE"),
RoadmapPhase(number=7, name="Data Quality and Observability", status="COMPLETE"),
- RoadmapPhase(number=8, name="Lineage and Impact Analysis", status="IN_PROGRESS"),
- RoadmapPhase(number=9, name="Analytical Marts and Semantic Delivery", status="PLANNED"),
+ RoadmapPhase(number=8, name="Lineage and Impact Analysis", status="COMPLETE"),
+ RoadmapPhase(number=9, name="Analytical Marts and Semantic Delivery", status="IN_PROGRESS"),
RoadmapPhase(number=10, name="Power BI Integration", status="PLANNED"),
]
@@ -96,7 +96,9 @@ def platform_summary() -> PlatformSummary:
product="SkyData Studio",
subtitle="Data Engineering Workbench",
theme="Aurora Foundry",
- current_phase="Phase 8.5 β Analytics Consumer Lineage and Impact Closure",
+ current_phase=(
+ "Phase 9.1 β Analytical Mart Publication Readiness and Freshness Gate"
+ ),
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/analytics.py b/apps/api/skydata_studio/schemas/analytics.py
new file mode 100644
index 0000000..d52bad5
--- /dev/null
+++ b/apps/api/skydata_studio/schemas/analytics.py
@@ -0,0 +1,68 @@
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+AnalyticsProductStatus = Literal["READY", "STALE", "BLOCKED", "PENDING", "MISSING"]
+AnalyticsGateStatus = Literal["PASS", "WARN", "BLOCK", "PENDING"]
+AnalyticsFreshnessStatus = Literal["ALIGNED", "STALE", "MISSING", "UNKNOWN"]
+
+
+class AnalyticsProductDefinition(BaseModel):
+ code: str
+ version: str
+ name: str
+ description: str
+ owner: str
+ domain: str
+ source_relation: str
+ mart_relation: str
+ freshness_column: str
+ row_alignment: Literal["EXACT"] = "EXACT"
+ semantic_model: str
+ quality_contract_code: str
+ required_metrics: list[str]
+ required_dimensions: list[str]
+ consumer_codes: list[str]
+ publication_mode: Literal["GATED"] = "GATED"
+
+
+class AnalyticsRelationEvidence(BaseModel):
+ relation: str
+ status: Literal["READY", "MISSING"]
+ row_count: int | None = Field(default=None, ge=0)
+ max_freshness_value: str | None = None
+
+
+class AnalyticsPublicationGate(BaseModel):
+ code: str
+ label: str
+ status: AnalyticsGateStatus
+ message: str
+
+
+class AnalyticsProductSummary(BaseModel):
+ project_name: str = "skydata_studio"
+ phase: str = "Phase 9.1 β Analytical Mart Publication Readiness and Freshness Gate"
+ product_status: AnalyticsProductStatus
+ product_code: str
+ product_version: str
+ product_name: str
+ description: str
+ owner: str
+ domain: str
+ source_path: str
+ source_relation: AnalyticsRelationEvidence
+ mart_relation: AnalyticsRelationEvidence
+ row_count_delta: int | None = None
+ freshness_status: AnalyticsFreshnessStatus
+ refresh_required: bool
+ model_build_status: Literal["READY", "ERROR", "UNKNOWN", "MISSING"]
+ semantic_artifact_status: Literal["READY", "PENDING", "MISSING"]
+ semantic_model_resolved: bool
+ quality_contract_status: Literal["COMPLIANT", "DEGRADED", "BLOCKED", "PENDING"]
+ required_metric_count: int = Field(ge=0)
+ resolved_metric_count: int = Field(ge=0)
+ required_consumer_count: int = Field(ge=0)
+ resolved_consumer_count: int = Field(ge=0)
+ gates: list[AnalyticsPublicationGate]
+ publication_message: str
diff --git a/apps/api/skydata_studio/services/analytics_products.py b/apps/api/skydata_studio/services/analytics_products.py
new file mode 100644
index 0000000..ab6aac1
--- /dev/null
+++ b/apps/api/skydata_studio/services/analytics_products.py
@@ -0,0 +1,429 @@
+from __future__ import annotations
+
+import json
+import re
+from datetime import date, datetime
+from pathlib import Path
+from typing import Any, Literal, cast
+
+from sqlalchemy import inspect, text
+from sqlalchemy.orm import Session
+
+from skydata_studio.schemas.analytics import (
+ AnalyticsProductDefinition,
+ AnalyticsProductSummary,
+ AnalyticsPublicationGate,
+ AnalyticsRelationEvidence,
+)
+from skydata_studio.schemas.dbt import (
+ DbtModelCatalogueSummary,
+ DbtSemanticLayerSummary,
+)
+from skydata_studio.schemas.lineage import AnalyticsConsumerLineageSummary
+from skydata_studio.schemas.quality import QualityContractSummary
+from skydata_studio.services.dbt_transformations import (
+ dbt_model_catalogue,
+ dbt_semantic_layer,
+)
+from skydata_studio.services.lineage_consumers import analytics_consumer_lineage_summary
+from skydata_studio.services.quality_contracts import quality_contract_summary
+
+_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
+
+
+def _repository_root() -> Path:
+ return Path(__file__).resolve().parents[4]
+
+
+def _default_product_path() -> Path:
+ return (
+ _repository_root()
+ / "contracts"
+ / "analytics"
+ / "products"
+ / "fed_funds_rate_daily_product.v1.json"
+ )
+
+
+def _consumer_contract_directory() -> Path:
+ return _repository_root() / "contracts" / "analytics"
+
+
+def _read_product(path: Path) -> AnalyticsProductDefinition:
+ with path.open(encoding="utf-8") as handle:
+ payload = json.load(handle)
+ if not isinstance(payload, dict):
+ raise ValueError(f"Expected an object in analytical product contract {path}.")
+ return AnalyticsProductDefinition.model_validate(cast(dict[str, Any], payload))
+
+
+def _relation_parts(relation: str) -> tuple[str, str]:
+ parts = relation.split(".")
+ if len(parts) != 2 or not all(_IDENTIFIER.fullmatch(part) for part in parts):
+ raise ValueError(f"Unsafe analytical relation identifier: {relation!r}.")
+ return parts[0], parts[1]
+
+
+def _column_name(column: str) -> str:
+ if not _IDENTIFIER.fullmatch(column):
+ raise ValueError(f"Unsafe analytical freshness column: {column!r}.")
+ return column
+
+
+def _scalar_to_text(value: object) -> str | None:
+ if value is None:
+ return None
+ if isinstance(value, datetime):
+ return value.isoformat()
+ if isinstance(value, date):
+ return value.isoformat()
+ return str(value)
+
+
+def relation_evidence(
+ session: Session,
+ relation: str,
+ freshness_column: str,
+) -> AnalyticsRelationEvidence:
+ schema, relation_name = _relation_parts(relation)
+ column = _column_name(freshness_column)
+ inspector = inspect(session.get_bind())
+ if not inspector.has_table(relation_name, schema=schema):
+ return AnalyticsRelationEvidence(relation=relation, status="MISSING")
+
+ count_statement = text(f'SELECT COUNT(*) FROM "{schema}"."{relation_name}"')
+ freshness_statement = text(
+ f'SELECT MAX("{column}") FROM "{schema}"."{relation_name}"'
+ )
+ row_count = int(session.execute(count_statement).scalar_one())
+ freshness_value = session.execute(freshness_statement).scalar_one()
+ return AnalyticsRelationEvidence(
+ relation=relation,
+ status="READY",
+ row_count=row_count,
+ max_freshness_value=_scalar_to_text(freshness_value),
+ )
+
+
+def _model_build_status(
+ product: AnalyticsProductDefinition,
+ catalogue: DbtModelCatalogueSummary,
+) -> Literal["READY", "ERROR", "UNKNOWN", "MISSING"]:
+ model = next(
+ (item for item in catalogue.models if item.relation == product.mart_relation),
+ None,
+ )
+ if model is None:
+ return "MISSING"
+ return model.build_status
+
+
+def _semantic_resolution(
+ product: AnalyticsProductDefinition,
+ semantic: DbtSemanticLayerSummary,
+) -> tuple[bool, int]:
+ semantic_model = next(
+ (item for item in semantic.semantic_models if item.name == product.semantic_model),
+ None,
+ )
+ if semantic_model is None or semantic.artifact_status != "READY":
+ return False, 0
+
+ available_dimensions = {item.name for item in semantic_model.dimensions}
+ dimensions_ready = all(
+ name in available_dimensions for name in product.required_dimensions
+ )
+ available_metrics = {
+ item.name
+ for item in semantic.metrics
+ if item.semantic_model == product.semantic_model
+ }
+ resolved_metric_count = sum(
+ metric_name in available_metrics for metric_name in product.required_metrics
+ )
+ return (
+ dimensions_ready and resolved_metric_count == len(product.required_metrics),
+ resolved_metric_count,
+ )
+
+
+def _consumer_resolution(
+ product: AnalyticsProductDefinition,
+ consumers: AnalyticsConsumerLineageSummary,
+) -> tuple[int, int]:
+ required_codes = set(product.consumer_codes)
+ resolved_codes = {
+ str(node.metadata.get("consumer_code") or "")
+ for node in consumers.nodes
+ if node.node_type == "ANALYTICS_CONSUMER" and node.status == "READY"
+ }
+ return len(required_codes), len(required_codes & resolved_codes)
+
+
+def _freshness_status(
+ product: AnalyticsProductDefinition,
+ source: AnalyticsRelationEvidence,
+ mart: AnalyticsRelationEvidence,
+) -> Literal["ALIGNED", "STALE", "MISSING", "UNKNOWN"]:
+ if source.status == "MISSING" or mart.status == "MISSING":
+ return "MISSING"
+ if source.row_count is None or mart.row_count is None:
+ return "UNKNOWN"
+ if source.max_freshness_value is None or mart.max_freshness_value is None:
+ return "UNKNOWN"
+
+ rows_aligned = source.row_count == mart.row_count
+ dates_aligned = source.max_freshness_value == mart.max_freshness_value
+ if product.row_alignment == "EXACT" and rows_aligned and dates_aligned:
+ return "ALIGNED"
+ return "STALE"
+
+
+def _gate(
+ code: str,
+ label: str,
+ status: Literal["PASS", "WARN", "BLOCK", "PENDING"],
+ message: str,
+) -> AnalyticsPublicationGate:
+ return AnalyticsPublicationGate(
+ code=code,
+ label=label,
+ status=status,
+ message=message,
+ )
+
+
+def compose_analytics_product_summary(
+ *,
+ product: AnalyticsProductDefinition,
+ source_path: str,
+ source: AnalyticsRelationEvidence,
+ mart: AnalyticsRelationEvidence,
+ catalogue: DbtModelCatalogueSummary,
+ semantic: DbtSemanticLayerSummary,
+ quality: QualityContractSummary,
+ consumers: AnalyticsConsumerLineageSummary,
+) -> AnalyticsProductSummary:
+ model_status = _model_build_status(product, catalogue)
+ semantic_resolved, resolved_metric_count = _semantic_resolution(product, semantic)
+ required_consumer_count, resolved_consumer_count = _consumer_resolution(product, consumers)
+ freshness_status = _freshness_status(product, source, mart)
+
+ row_count_delta = None
+ if source.row_count is not None and mart.row_count is not None:
+ row_count_delta = source.row_count - mart.row_count
+
+ gates: list[AnalyticsPublicationGate] = []
+ physical_ready = source.status == "READY" and mart.status == "READY"
+ gates.append(
+ _gate(
+ "PHYSICAL_RELATIONS",
+ "Source and mart relations exist",
+ "PASS" if physical_ready else "BLOCK",
+ (
+ "Curated source and dbt analytical mart are both materialized."
+ if physical_ready
+ else "The curated source or dbt analytical mart relation is missing."
+ ),
+ )
+ )
+
+ model_gate_status: Literal["PASS", "WARN", "BLOCK", "PENDING"]
+ if model_status == "READY":
+ model_gate_status = "PASS"
+ elif model_status == "UNKNOWN":
+ model_gate_status = "PENDING"
+ else:
+ model_gate_status = "BLOCK"
+ gates.append(
+ _gate(
+ "DBT_MODEL_BUILD",
+ "dbt mart build evidence is green",
+ model_gate_status,
+ f"Latest dbt model status is {model_status}.",
+ )
+ )
+
+ freshness_gate_status: Literal["PASS", "WARN", "BLOCK", "PENDING"]
+ if freshness_status == "ALIGNED":
+ freshness_gate_status = "PASS"
+ freshness_message = (
+ "Curated source and analytical mart have matching row counts and latest dates."
+ )
+ elif freshness_status == "STALE":
+ freshness_gate_status = "BLOCK"
+ freshness_message = (
+ "Curated source is ahead of the analytical mart; run dbt build before publication."
+ )
+ elif freshness_status == "MISSING":
+ freshness_gate_status = "BLOCK"
+ freshness_message = "Freshness cannot be proven because a required relation is missing."
+ else:
+ freshness_gate_status = "PENDING"
+ freshness_message = "Freshness evidence is incomplete."
+ gates.append(
+ _gate(
+ "FRESHNESS_ALIGNMENT",
+ "Curated source and dbt mart are synchronized",
+ freshness_gate_status,
+ freshness_message,
+ )
+ )
+
+ quality_matches = quality.contract_code == product.quality_contract_code
+ quality_pass = quality_matches and quality.contract_status == "COMPLIANT"
+ quality_gate_status: Literal["PASS", "WARN", "BLOCK", "PENDING"]
+ if quality_pass:
+ quality_gate_status = "PASS"
+ elif quality.contract_status == "PENDING":
+ quality_gate_status = "PENDING"
+ else:
+ quality_gate_status = "BLOCK"
+ gates.append(
+ _gate(
+ "QUALITY_CONTRACT",
+ "Quality contract permits consumption",
+ quality_gate_status,
+ (
+ "Required quality contract is COMPLIANT."
+ if quality_pass
+ else (
+ "Required quality contract does not match the analytical product."
+ if not quality_matches
+ else f"Quality contract is {quality.contract_status}."
+ )
+ ),
+ )
+ )
+
+ semantic_gate_status: Literal["PASS", "WARN", "BLOCK", "PENDING"]
+ if semantic_resolved:
+ semantic_gate_status = "PASS"
+ elif semantic.artifact_status == "READY":
+ semantic_gate_status = "BLOCK"
+ else:
+ semantic_gate_status = "PENDING"
+ gates.append(
+ _gate(
+ "SEMANTIC_PRODUCT",
+ "Semantic model and governed metrics resolve",
+ semantic_gate_status,
+ (
+ f"Resolved {resolved_metric_count}/"
+ f"{len(product.required_metrics)} required metrics."
+ ),
+ )
+ )
+
+ consumer_ready = (
+ required_consumer_count > 0
+ and resolved_consumer_count == required_consumer_count
+ )
+ consumer_gate_status: Literal["PASS", "WARN", "BLOCK", "PENDING"]
+ if consumer_ready:
+ consumer_gate_status = "PASS"
+ elif consumers.consumer_status == "MISSING":
+ consumer_gate_status = "BLOCK"
+ else:
+ consumer_gate_status = "PENDING"
+ gates.append(
+ _gate(
+ "DECLARED_CONSUMERS",
+ "Declared consumers resolve to governed semantics",
+ consumer_gate_status,
+ f"Resolved {resolved_consumer_count}/{required_consumer_count} required consumers.",
+ )
+ )
+
+ blocking_codes = {item.code for item in gates if item.status == "BLOCK"}
+ pending = any(item.status == "PENDING" for item in gates)
+ if not physical_ready:
+ product_status: Literal["READY", "STALE", "BLOCKED", "PENDING", "MISSING"] = (
+ "MISSING"
+ )
+ elif "FRESHNESS_ALIGNMENT" in blocking_codes:
+ product_status = "STALE"
+ elif blocking_codes:
+ product_status = "BLOCKED"
+ elif pending:
+ product_status = "PENDING"
+ else:
+ product_status = "READY"
+
+ if product_status == "READY":
+ publication_message = (
+ "Analytical product is synchronized, governed, semantically resolved, "
+ "and ready for delivery."
+ )
+ elif product_status == "STALE":
+ publication_message = (
+ "Analytical product is structurally healthy but stale relative to the curated source. "
+ "Run dbt build, then refresh this workbench."
+ )
+ elif product_status == "BLOCKED":
+ publication_message = "Analytical publication is blocked by one or more governance gates."
+ elif product_status == "MISSING":
+ publication_message = "Analytical publication is missing required physical data products."
+ else:
+ publication_message = "Analytical publication is waiting for complete runtime evidence."
+
+ return AnalyticsProductSummary(
+ product_status=product_status,
+ product_code=product.code,
+ product_version=product.version,
+ product_name=product.name,
+ description=product.description,
+ owner=product.owner,
+ domain=product.domain,
+ source_path=source_path,
+ source_relation=source,
+ mart_relation=mart,
+ row_count_delta=row_count_delta,
+ freshness_status=freshness_status,
+ refresh_required=product_status == "STALE",
+ model_build_status=model_status,
+ semantic_artifact_status=semantic.artifact_status,
+ semantic_model_resolved=semantic_resolved,
+ quality_contract_status=quality.contract_status,
+ required_metric_count=len(product.required_metrics),
+ resolved_metric_count=resolved_metric_count,
+ required_consumer_count=required_consumer_count,
+ resolved_consumer_count=resolved_consumer_count,
+ gates=gates,
+ publication_message=publication_message,
+ )
+
+
+def analytics_product_summary(
+ session: Session,
+ *,
+ product_path: Path | None = None,
+ target_dir: Path | None = None,
+ consumer_contract_directory: Path | None = None,
+) -> AnalyticsProductSummary:
+ path = product_path or _default_product_path()
+ product = _read_product(path)
+ try:
+ source_path = path.relative_to(_repository_root()).as_posix()
+ except ValueError:
+ source_path = path.as_posix()
+
+ source = relation_evidence(session, product.source_relation, product.freshness_column)
+ mart = relation_evidence(session, product.mart_relation, product.freshness_column)
+ catalogue = dbt_model_catalogue(target_dir)
+ semantic = dbt_semantic_layer(target_dir)
+ quality = quality_contract_summary(target_dir=target_dir)
+ consumers = analytics_consumer_lineage_summary(
+ contract_directory=consumer_contract_directory or _consumer_contract_directory(),
+ target_dir=target_dir,
+ )
+ return compose_analytics_product_summary(
+ product=product,
+ source_path=source_path,
+ source=source,
+ mart=mart,
+ catalogue=catalogue,
+ semantic=semantic,
+ quality=quality,
+ consumers=consumers,
+ )
diff --git a/apps/web/src/App.jsx b/apps/web/src/App.jsx
index 41ed63c..84059e6 100644
--- a/apps/web/src/App.jsx
+++ b/apps/web/src/App.jsx
@@ -13,6 +13,7 @@ import SchedulesBackfills from './pages/SchedulesBackfills.jsx';
import Transformations from './pages/Transformations.jsx';
import DataModels from './pages/DataModels.jsx';
import SemanticLayer from './pages/SemanticLayer.jsx';
+import AnalyticalMarts from './pages/AnalyticalMarts.jsx';
import DataQuality from './pages/DataQuality.jsx';
import DataContracts from './pages/DataContracts.jsx';
import QualityIncidents from './pages/QualityIncidents.jsx';
@@ -75,6 +76,7 @@ function App() {
} />
} />
} />
+ } />
} />
} />
} />
@@ -96,6 +98,7 @@ function App() {
'/orchestration/airflow',
'/orchestration/runs',
'/orchestration/backfills',
+ '/delivery/marts',
'/delivery/semantic',
'/quality/checks',
'/quality/contracts',
diff --git a/apps/web/src/components/Sidebar.jsx b/apps/web/src/components/Sidebar.jsx
index f3f5f45..8afb446 100644
--- a/apps/web/src/components/Sidebar.jsx
+++ b/apps/web/src/components/Sidebar.jsx
@@ -122,7 +122,7 @@ function Sidebar({ mobileOpen, onClose }) {
Development
- Phase 8.5 consumer lineage
+ Phase 9.1 analytical mart readiness
diff --git a/apps/web/src/pages/AnalyticalMarts.jsx b/apps/web/src/pages/AnalyticalMarts.jsx
new file mode 100644
index 0000000..d545d04
--- /dev/null
+++ b/apps/web/src/pages/AnalyticalMarts.jsx
@@ -0,0 +1,241 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import StatusPill from '../components/StatusPill.jsx';
+import { getJson } from '../services/api.js';
+
+const EMPTY = {
+ product_status: 'PENDING',
+ product_code: 'FED_FUNDS_RATE_DAILY_PRODUCT',
+ product_version: '1.0.0',
+ product_name: 'Federal Funds Rate Daily Analytical Product',
+ description: 'Waiting for analytical publication evidence.',
+ owner: 'SkyData Studio',
+ domain: 'Macroeconomics',
+ source_path: 'contracts/analytics/products/fed_funds_rate_daily_product.v1.json',
+ source_relation: { relation: 'mart.fed_funds_rate', status: 'MISSING', row_count: null, max_freshness_value: null },
+ mart_relation: { relation: 'dbt_mart.fct_fed_funds_rate_daily', status: 'MISSING', row_count: null, max_freshness_value: null },
+ row_count_delta: null,
+ freshness_status: 'UNKNOWN',
+ refresh_required: false,
+ model_build_status: 'UNKNOWN',
+ semantic_artifact_status: 'PENDING',
+ semantic_model_resolved: false,
+ quality_contract_status: 'PENDING',
+ required_metric_count: 4,
+ resolved_metric_count: 0,
+ required_consumer_count: 1,
+ resolved_consumer_count: 0,
+ gates: [],
+ publication_message: 'Waiting for analytical publication evidence.',
+};
+
+function formatCount(value) {
+ if (value === null || value === undefined) return 'β';
+ return Number(value).toLocaleString();
+}
+
+function gateTone(status) {
+ if (status === 'PASS') return 'READY';
+ if (status === 'WARN' || status === 'PENDING') return 'WARNING';
+ if (status === 'BLOCK') return 'BLOCKED';
+ return 'UNKNOWN';
+}
+
+function statusTone(status) {
+ if (status === 'READY' || status === 'ALIGNED' || status === 'COMPLIANT') return 'READY';
+ if (status === 'STALE' || status === 'PENDING' || status === 'UNKNOWN') return 'WARNING';
+ if (status === 'BLOCKED' || status === 'MISSING' || status === 'ERROR') return 'BLOCKED';
+ return status;
+}
+
+function AnalyticalMarts() {
+ const [summary, setSummary] = useState(EMPTY);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError('');
+ try {
+ setSummary(await getJson('/api/v1/analytics/products/summary'));
+ } catch (requestError) {
+ setError(requestError.message);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ getJson('/api/v1/analytics/products/summary', { signal: controller.signal })
+ .then(setSummary)
+ .catch((requestError) => {
+ if (requestError.name !== 'AbortError') setError(requestError.message);
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setLoading(false);
+ });
+ return () => controller.abort();
+ }, []);
+
+ const passingGates = useMemo(
+ () => summary.gates.filter((gate) => gate.status === 'PASS').length,
+ [summary.gates],
+ );
+
+ return (
+
+
+
+ PHASE 9.1 Β· ANALYTICAL MART PUBLICATION READINESS
+
Analytical Marts
+
+ Treat a mart as publishable only when physical data, dbt build evidence, freshness,
+ quality policy, semantic definitions, and declared consumers all agree.
+
Freshness is a gateA healthy dbt model is not publishable when the curated source has moved ahead.
+
02
Governance composesPhysical, quality, semantic, and consumer evidence must all resolve together.
+
03
Publication stays explicitStudio reports readiness but does not silently execute dbt or deploy Power BI resources.
+
+
+
+
+
BOUNDARY
Gate the product; do not hide the refresh
+ β¦
+
+
+ Phase 9.1 turns the existing mart into a governed analytical product contract. dbt still
+ owns transformation execution, quality retains policy ownership, semantic definitions
+ remain dbt-owned, and downstream Power BI provisioning stays a later phase.
+
Studio composes evidence from physical relations, dbt artifacts, quality policy, semantic definitions, and consumer declarations while each authority remains independently owned.