diff --git a/README.md b/README.md
index 8292560..e3da7b2 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 6 are complete. **Phase 7.1 closed green** with 14/14 dbt checks passing, a TRUSTED latest-run posture, layer-aware quality evidence, and 60 local tests passing. **Phase 7.2 is now in progress:** source-controlled quality contracts turn latest dbt evidence into explicit consumer gates while the Contracts workbench keeps SkyCommand boundary compatibility visible.
+**Current status:** Phases 0 through 6 are complete. **Phase 7.1 and Phase 7.2 are closed green:** latest dbt quality evidence is TRUSTED at 14/14, the Federal Funds Rate consumer gate is COMPLIANT at 5/5 and 100%, all five SkyCommand boundary contracts remain compatible, and the final Phase 7.2 local validation passed 64 tests. **Phase 7.3 is now in progress:** real non-passing contract outcomes become durable Studio-owned incidents with acknowledgement, ownership, resolution, recurrence, and lifecycle history.
---
@@ -171,7 +171,28 @@ Core endpoint:
GET /api/v1/quality/contracts/summary
```
-Open `/quality/contracts` to inspect the mart gate and the existing SkyCommand consumer compatibility boundary together. The Federal Funds Rate proof requires five passing MART checks and a 100% pass rate. A missing or non-passing required rule blocks the contract; missing latest run evidence leaves it `PENDING`. Durable incident lifecycle remains a later Phase 7 slice.
+Open `/quality/contracts` to inspect the mart gate and the existing SkyCommand consumer compatibility boundary together. The Federal Funds Rate proof requires five passing MART checks and a 100% pass rate. A missing or non-passing required rule blocks the contract; missing latest run evidence leaves it `PENDING`. Phase 7.2 closed with the live gate COMPLIANT at 5/5 and all five SkyCommand consumer contracts COMPATIBLE.
+
+
+## Phase 7.3 quick start
+
+Phase 7.3 adds Studio-owned runtime persistence for quality incidents without copying dbt test definitions or source-controlled contract policy. Re-run the idempotent PostgreSQL bootstrap so the two incident tables are created:
+
+```powershell
+docker compose -f .\infra\docker-compose.yml up -d studio-postgres
+uv run python .\scripts\bootstrap_metadata.py
+```
+
+Then reconcile the current contract evidence into the durable register:
+
+```text
+GET /api/v1/quality/incidents/summary
+POST /api/v1/quality/incidents/reconcile
+POST /api/v1/quality/incidents/{incidentId}/acknowledge
+POST /api/v1/quality/incidents/{incidentId}/resolve
+```
+
+Open `/quality/incidents`. A clean 5/5 contract intentionally creates **zero** incidents. `WARN`, `BLOCK`, or `MISSING` rule outcomes create one durable incident per contract rule; operators can acknowledge and manually resolve it, PASS evidence auto-resolves it, and a later recurrence reopens the same record while preserving event history. Manual resolution is not a waiver: reconciliation reopens an incident if failing evidence remains.
---
diff --git a/apps/api/skydata_studio/api/routes/platform.py b/apps/api/skydata_studio/api/routes/platform.py
index 53f03ed..47e50ba 100644
--- a/apps/api/skydata_studio/api/routes/platform.py
+++ b/apps/api/skydata_studio/api/routes/platform.py
@@ -83,7 +83,7 @@ def platform_summary() -> PlatformSummary:
product="SkyData Studio",
subtitle="Data Engineering Workbench",
theme="Aurora Foundry",
- current_phase="Phase 7.2 — Quality Contract Gate and Consumer Compatibility Workbench",
+ current_phase="Phase 7.3 — Durable Quality Incidents and Remediation Lifecycle",
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/api/routes/quality.py b/apps/api/skydata_studio/api/routes/quality.py
index a20a94c..c008c14 100644
--- a/apps/api/skydata_studio/api/routes/quality.py
+++ b/apps/api/skydata_studio/api/routes/quality.py
@@ -1,8 +1,25 @@
from fastapi import APIRouter, HTTPException, status
+from sqlalchemy.exc import SQLAlchemyError
-from skydata_studio.schemas.quality import DbtQualitySummary, QualityContractSummary
+from skydata_studio.db.session import SessionDependency
+from skydata_studio.schemas.quality import (
+ DbtQualitySummary,
+ QualityContractSummary,
+ QualityIncidentAction,
+ QualityIncidentRead,
+ QualityIncidentReconcileResult,
+ QualityIncidentSummary,
+)
from skydata_studio.services.dbt_quality import dbt_quality_summary
from skydata_studio.services.quality_contracts import quality_contract_summary
+from skydata_studio.services.quality_incidents import (
+ QualityIncidentNotFoundError,
+ QualityIncidentTransitionError,
+ acknowledge_quality_incident,
+ quality_incident_summary,
+ reconcile_quality_incidents,
+ resolve_quality_incident,
+)
router = APIRouter()
@@ -14,6 +31,16 @@ def _artifact_unavailable(message: str) -> HTTPException:
)
+def _incident_storage_unavailable() -> HTTPException:
+ return HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail=(
+ "SkyData Studio quality incident storage is unavailable. Start studio-postgres "
+ "and run uv run python scripts/bootstrap_metadata.py."
+ ),
+ )
+
+
@router.get("/dbt/summary", response_model=DbtQualitySummary)
def dbt_quality() -> DbtQualitySummary:
try:
@@ -35,3 +62,64 @@ def quality_contract() -> QualityContractSummary:
"Confirm the source-controlled contract exists, run .\\scripts\\dbt.ps1 build, "
"and refresh Contracts."
) from error
+
+
+@router.get("/incidents/summary", response_model=QualityIncidentSummary)
+def incident_summary(session: SessionDependency) -> QualityIncidentSummary:
+ try:
+ return quality_incident_summary(session)
+ except SQLAlchemyError as error:
+ raise _incident_storage_unavailable() from error
+ except (OSError, ValueError, TypeError) as error:
+ raise _artifact_unavailable(
+ "SkyData Studio could not evaluate current quality evidence for incidents. "
+ "Run .\\scripts\\dbt.ps1 build and retry."
+ ) from error
+
+
+@router.post("/incidents/reconcile", response_model=QualityIncidentReconcileResult)
+def incident_reconcile(session: SessionDependency) -> QualityIncidentReconcileResult:
+ try:
+ return reconcile_quality_incidents(session)
+ except SQLAlchemyError as error:
+ session.rollback()
+ raise _incident_storage_unavailable() from error
+ except (OSError, ValueError, TypeError) as error:
+ session.rollback()
+ raise _artifact_unavailable(
+ "SkyData Studio could not reconcile durable incidents from current quality evidence."
+ ) from error
+
+
+@router.post("/incidents/{incident_id}/acknowledge", response_model=QualityIncidentRead)
+def incident_acknowledge(
+ incident_id: str,
+ payload: QualityIncidentAction,
+ session: SessionDependency,
+) -> QualityIncidentRead:
+ try:
+ return acknowledge_quality_incident(session, incident_id, payload)
+ except QualityIncidentNotFoundError as error:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
+ except QualityIncidentTransitionError as error:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
+ except SQLAlchemyError as error:
+ session.rollback()
+ raise _incident_storage_unavailable() from error
+
+
+@router.post("/incidents/{incident_id}/resolve", response_model=QualityIncidentRead)
+def incident_resolve(
+ incident_id: str,
+ payload: QualityIncidentAction,
+ session: SessionDependency,
+) -> QualityIncidentRead:
+ try:
+ return resolve_quality_incident(session, incident_id, payload)
+ except QualityIncidentNotFoundError as error:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
+ except QualityIncidentTransitionError as error:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
+ except SQLAlchemyError as error:
+ session.rollback()
+ raise _incident_storage_unavailable() from error
diff --git a/apps/api/skydata_studio/db/bootstrap.py b/apps/api/skydata_studio/db/bootstrap.py
index 26c38b3..04c9da2 100644
--- a/apps/api/skydata_studio/db/bootstrap.py
+++ b/apps/api/skydata_studio/db/bootstrap.py
@@ -4,6 +4,7 @@
from skydata_studio.db.session import get_engine
from skydata_studio.models import metadata as _metadata_models # noqa: F401
from skydata_studio.models import pipeline as _pipeline_models # noqa: F401
+from skydata_studio.models import quality as _quality_models # noqa: F401
def create_metadata_schema(engine: Engine | None = None) -> None:
diff --git a/apps/api/skydata_studio/models/__init__.py b/apps/api/skydata_studio/models/__init__.py
index 5cafb0a..be51af0 100644
--- a/apps/api/skydata_studio/models/__init__.py
+++ b/apps/api/skydata_studio/models/__init__.py
@@ -18,6 +18,7 @@
PipelineStepRun,
PipelineVersion,
)
+from skydata_studio.models.quality import QualityIncident, QualityIncidentEvent
__all__ = [
"MetadataAsset",
@@ -36,4 +37,6 @@
"PipelineStepDependency",
"PipelineStepRun",
"PipelineVersion",
+ "QualityIncident",
+ "QualityIncidentEvent",
]
diff --git a/apps/api/skydata_studio/models/quality.py b/apps/api/skydata_studio/models/quality.py
new file mode 100644
index 0000000..ecbba48
--- /dev/null
+++ b/apps/api/skydata_studio/models/quality.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from uuid import uuid4
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from skydata_studio.db.base import Base
+
+
+def _uuid() -> str:
+ return str(uuid4())
+
+
+def _utc_now() -> datetime:
+ return datetime.now(UTC)
+
+
+class QualityIncident(Base):
+ __tablename__ = "quality_incident"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
+ incident_key: Mapped[str] = mapped_column(String(320), unique=True, index=True)
+ contract_code: Mapped[str] = mapped_column(String(160), index=True)
+ contract_version: Mapped[str] = mapped_column(String(40))
+ rule_code: Mapped[str] = mapped_column(String(160), index=True)
+ rule_label: Mapped[str] = mapped_column(String(255))
+ target_name: Mapped[str] = mapped_column(String(255), index=True)
+ layer: Mapped[str] = mapped_column(String(40), index=True)
+ quality_dimension: Mapped[str] = mapped_column(String(60), index=True)
+ severity: Mapped[str] = mapped_column(String(40), index=True)
+ status: Mapped[str] = mapped_column(String(40), default="OPEN", index=True)
+ evidence_outcome: Mapped[str] = mapped_column(String(40))
+ matched_check_name: Mapped[str | None] = mapped_column(String(500))
+ matched_status: Mapped[str | None] = mapped_column(String(40))
+ message: Mapped[str] = mapped_column(Text)
+ occurrence_count: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
+ first_detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ last_detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+ acknowledged_by: Mapped[str | None] = mapped_column(String(160))
+ resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+ resolution_note: Mapped[str | None] = mapped_column(Text)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=_utc_now, nullable=False
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=_utc_now, onupdate=_utc_now, nullable=False
+ )
+
+ events: Mapped[list[QualityIncidentEvent]] = relationship(
+ back_populates="incident",
+ cascade="all, delete-orphan",
+ order_by="QualityIncidentEvent.created_at",
+ )
+
+
+class QualityIncidentEvent(Base):
+ __tablename__ = "quality_incident_event"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
+ incident_id: Mapped[str] = mapped_column(
+ ForeignKey("quality_incident.id", ondelete="CASCADE"),
+ index=True,
+ )
+ event_type: Mapped[str] = mapped_column(String(40), index=True)
+ actor: Mapped[str | None] = mapped_column(String(160))
+ note: Mapped[str | None] = mapped_column(Text)
+ evidence_outcome: Mapped[str | None] = mapped_column(String(40))
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=_utc_now, nullable=False
+ )
+
+ incident: Mapped[QualityIncident] = relationship(back_populates="events")
diff --git a/apps/api/skydata_studio/schemas/quality.py b/apps/api/skydata_studio/schemas/quality.py
index 14ae49b..6fdbdf7 100644
--- a/apps/api/skydata_studio/schemas/quality.py
+++ b/apps/api/skydata_studio/schemas/quality.py
@@ -1,6 +1,7 @@
+from datetime import datetime
from typing import Literal
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, ConfigDict, Field
QualityDimension = Literal[
"COMPLETENESS",
@@ -110,3 +111,79 @@ class QualityContractSummary(BaseModel):
missing_rule_count: int = Field(ge=0)
source_path: str
rules: list[QualityContractRuleEvaluation]
+
+
+QualityIncidentStatus = Literal["OPEN", "ACKNOWLEDGED", "RESOLVED"]
+QualityIncidentSeverity = Literal["WARNING", "BLOCKING"]
+QualityIncidentEventType = Literal["OPENED", "REOPENED", "ACKNOWLEDGED", "RESOLVED"]
+
+
+class QualityIncidentEventRead(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: str
+ event_type: QualityIncidentEventType
+ actor: str | None = None
+ note: str | None = None
+ evidence_outcome: Literal["PASS", "WARN", "BLOCK", "MISSING"] | None = None
+ created_at: datetime
+
+
+class QualityIncidentRead(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: str
+ incident_key: str
+ contract_code: str
+ contract_version: str
+ rule_code: str
+ rule_label: str
+ target_name: str
+ layer: Literal["STAGING", "INTERMEDIATE", "MART"]
+ quality_dimension: QualityDimension
+ severity: QualityIncidentSeverity
+ status: QualityIncidentStatus
+ evidence_outcome: Literal["WARN", "BLOCK", "MISSING"]
+ matched_check_name: str | None = None
+ matched_status: QualityStatus | None = None
+ message: str
+ occurrence_count: int = Field(ge=1)
+ first_detected_at: datetime
+ last_detected_at: datetime
+ acknowledged_at: datetime | None = None
+ acknowledged_by: str | None = None
+ resolved_at: datetime | None = None
+ resolution_note: str | None = None
+ created_at: datetime
+ updated_at: datetime
+ events: list[QualityIncidentEventRead]
+
+
+class QualityIncidentAction(BaseModel):
+ actor: str = Field(min_length=1, max_length=160)
+ note: str | None = Field(default=None, max_length=2000)
+
+
+class QualityIncidentSummary(BaseModel):
+ project_name: str = "skydata_studio"
+ phase: str = "Phase 7.3 — Durable Quality Incidents and Remediation Lifecycle"
+ contract_code: str
+ contract_status: Literal["COMPLIANT", "DEGRADED", "BLOCKED", "PENDING"]
+ artifact_status: Literal["READY", "PENDING", "MISSING"]
+ total_count: int = Field(ge=0)
+ active_count: int = Field(ge=0)
+ open_count: int = Field(ge=0)
+ acknowledged_count: int = Field(ge=0)
+ resolved_count: int = Field(ge=0)
+ blocking_active_count: int = Field(ge=0)
+ warning_active_count: int = Field(ge=0)
+ incidents: list[QualityIncidentRead]
+
+
+class QualityIncidentReconcileResult(BaseModel):
+ project_name: str = "skydata_studio"
+ phase: str = "Phase 7.3 — Durable Quality Incidents and Remediation Lifecycle"
+ created_count: int = Field(ge=0)
+ reopened_count: int = Field(ge=0)
+ resolved_count: int = Field(ge=0)
+ summary: QualityIncidentSummary
diff --git a/apps/api/skydata_studio/services/quality_incidents.py b/apps/api/skydata_studio/services/quality_incidents.py
new file mode 100644
index 0000000..5ef8e48
--- /dev/null
+++ b/apps/api/skydata_studio/services/quality_incidents.py
@@ -0,0 +1,301 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from typing import Literal, cast
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session, selectinload
+
+from skydata_studio.models.quality import QualityIncident, QualityIncidentEvent
+from skydata_studio.schemas.quality import (
+ QualityContractRuleEvaluation,
+ QualityContractSummary,
+ QualityIncidentAction,
+ QualityIncidentRead,
+ QualityIncidentReconcileResult,
+ QualityIncidentSummary,
+)
+from skydata_studio.services.quality_contracts import quality_contract_summary
+
+IssueOutcome = Literal["WARN", "BLOCK", "MISSING"]
+
+
+class QualityIncidentNotFoundError(ValueError):
+ pass
+
+
+class QualityIncidentTransitionError(ValueError):
+ pass
+
+
+def _utc_now() -> datetime:
+ return datetime.now(UTC)
+
+
+def _incident_key(contract_code: str, rule_code: str) -> str:
+ return f"{contract_code}:{rule_code}"
+
+
+def _issue_outcome(rule: QualityContractRuleEvaluation) -> IssueOutcome | None:
+ if rule.outcome in {"WARN", "BLOCK", "MISSING"}:
+ return cast(IssueOutcome, rule.outcome)
+ return None
+
+
+def _severity(outcome: IssueOutcome) -> Literal["WARNING", "BLOCKING"]:
+ return "WARNING" if outcome == "WARN" else "BLOCKING"
+
+
+def _event(
+ incident: QualityIncident,
+ event_type: Literal["OPENED", "REOPENED", "ACKNOWLEDGED", "RESOLVED"],
+ *,
+ actor: str | None,
+ note: str | None,
+ evidence_outcome: Literal["PASS", "WARN", "BLOCK", "MISSING"] | None,
+) -> None:
+ incident.events.append(
+ QualityIncidentEvent(
+ event_type=event_type,
+ actor=actor,
+ note=note,
+ evidence_outcome=evidence_outcome,
+ )
+ )
+
+
+def _active_incidents(session: Session, contract_code: str) -> list[QualityIncident]:
+ statement = (
+ select(QualityIncident)
+ .where(QualityIncident.contract_code == contract_code)
+ .options(selectinload(QualityIncident.events))
+ )
+ return list(session.scalars(statement).unique().all())
+
+
+def _read(incident: QualityIncident) -> QualityIncidentRead:
+ return QualityIncidentRead.model_validate(incident)
+
+
+def _summary_from_contract(
+ session: Session,
+ contract: QualityContractSummary,
+) -> QualityIncidentSummary:
+ incidents = _active_incidents(session, contract.contract_code)
+ incidents.sort(
+ key=lambda incident: (
+ incident.status == "RESOLVED",
+ -incident.updated_at.timestamp(),
+ )
+ )
+ active = [incident for incident in incidents if incident.status != "RESOLVED"]
+ return QualityIncidentSummary(
+ contract_code=contract.contract_code,
+ contract_status=contract.contract_status,
+ artifact_status=contract.artifact_status,
+ total_count=len(incidents),
+ active_count=len(active),
+ open_count=sum(incident.status == "OPEN" for incident in incidents),
+ acknowledged_count=sum(incident.status == "ACKNOWLEDGED" for incident in incidents),
+ resolved_count=sum(incident.status == "RESOLVED" for incident in incidents),
+ blocking_active_count=sum(
+ incident.status != "RESOLVED" and incident.severity == "BLOCKING"
+ for incident in incidents
+ ),
+ warning_active_count=sum(
+ incident.status != "RESOLVED" and incident.severity == "WARNING"
+ for incident in incidents
+ ),
+ incidents=[_read(incident) for incident in incidents],
+ )
+
+
+def quality_incident_summary(
+ session: Session,
+ *,
+ contract: QualityContractSummary | None = None,
+) -> QualityIncidentSummary:
+ return _summary_from_contract(session, contract or quality_contract_summary())
+
+
+def reconcile_quality_incidents(
+ session: Session,
+ *,
+ contract: QualityContractSummary | None = None,
+) -> QualityIncidentReconcileResult:
+ current = contract or quality_contract_summary()
+ incidents = _active_incidents(session, current.contract_code)
+ by_key = {incident.incident_key: incident for incident in incidents}
+ now = _utc_now()
+ created_count = 0
+ reopened_count = 0
+ resolved_count = 0
+ active_rule_keys: set[str] = set()
+
+ for rule in current.rules:
+ key = _incident_key(current.contract_code, rule.code)
+ active_rule_keys.add(key)
+ incident = by_key.get(key)
+ issue = _issue_outcome(rule)
+
+ if issue is not None:
+ if incident is None:
+ incident = QualityIncident(
+ incident_key=key,
+ contract_code=current.contract_code,
+ contract_version=current.contract_version,
+ rule_code=rule.code,
+ rule_label=rule.label,
+ target_name=current.target_name,
+ layer=current.layer,
+ quality_dimension=rule.quality_dimension,
+ severity=_severity(issue),
+ status="OPEN",
+ evidence_outcome=issue,
+ matched_check_name=rule.matched_check_name,
+ matched_status=rule.matched_status,
+ message=rule.message,
+ occurrence_count=1,
+ first_detected_at=now,
+ last_detected_at=now,
+ )
+ _event(
+ incident,
+ "OPENED",
+ actor="SYSTEM",
+ note="Latest contract evidence created a durable quality incident.",
+ evidence_outcome=issue,
+ )
+ session.add(incident)
+ by_key[key] = incident
+ created_count += 1
+ continue
+
+ incident.contract_version = current.contract_version
+ incident.rule_label = rule.label
+ incident.target_name = current.target_name
+ incident.layer = current.layer
+ incident.quality_dimension = rule.quality_dimension
+ incident.severity = _severity(issue)
+ incident.evidence_outcome = issue
+ incident.matched_check_name = rule.matched_check_name
+ incident.matched_status = rule.matched_status
+ incident.message = rule.message
+ incident.last_detected_at = now
+
+ if incident.status == "RESOLVED":
+ incident.status = "OPEN"
+ incident.occurrence_count += 1
+ incident.acknowledged_at = None
+ incident.acknowledged_by = None
+ incident.resolved_at = None
+ incident.resolution_note = None
+ _event(
+ incident,
+ "REOPENED",
+ actor="SYSTEM",
+ note="Failing contract evidence returned after resolution.",
+ evidence_outcome=issue,
+ )
+ reopened_count += 1
+ continue
+
+ if rule.outcome == "PASS" and incident is not None and incident.status != "RESOLVED":
+ incident.status = "RESOLVED"
+ incident.resolved_at = now
+ incident.resolution_note = "Latest quality contract evidence returned to PASS."
+ _event(
+ incident,
+ "RESOLVED",
+ actor="SYSTEM",
+ note=incident.resolution_note,
+ evidence_outcome="PASS",
+ )
+ resolved_count += 1
+
+ for incident in incidents:
+ if incident.incident_key in active_rule_keys or incident.status == "RESOLVED":
+ continue
+ incident.status = "RESOLVED"
+ incident.resolved_at = now
+ incident.resolution_note = "Rule is no longer present in the active quality contract."
+ _event(
+ incident,
+ "RESOLVED",
+ actor="SYSTEM",
+ note=incident.resolution_note,
+ evidence_outcome=None,
+ )
+ resolved_count += 1
+
+ session.commit()
+ return QualityIncidentReconcileResult(
+ created_count=created_count,
+ reopened_count=reopened_count,
+ resolved_count=resolved_count,
+ summary=_summary_from_contract(session, current),
+ )
+
+
+def _get_incident(session: Session, incident_id: str) -> QualityIncident:
+ statement = (
+ select(QualityIncident)
+ .where(QualityIncident.id == incident_id)
+ .options(selectinload(QualityIncident.events))
+ )
+ incident = session.scalar(statement)
+ if incident is None:
+ raise QualityIncidentNotFoundError(f"Quality incident {incident_id} was not found.")
+ return incident
+
+
+def acknowledge_quality_incident(
+ session: Session,
+ incident_id: str,
+ action: QualityIncidentAction,
+) -> QualityIncidentRead:
+ incident = _get_incident(session, incident_id)
+ if incident.status == "RESOLVED":
+ raise QualityIncidentTransitionError("Resolved incidents cannot be acknowledged.")
+ if incident.status == "ACKNOWLEDGED":
+ raise QualityIncidentTransitionError("Quality incident is already acknowledged.")
+
+ now = _utc_now()
+ incident.status = "ACKNOWLEDGED"
+ incident.acknowledged_at = now
+ incident.acknowledged_by = action.actor
+ _event(
+ incident,
+ "ACKNOWLEDGED",
+ actor=action.actor,
+ note=action.note,
+ evidence_outcome=cast(IssueOutcome, incident.evidence_outcome),
+ )
+ session.commit()
+ session.refresh(incident)
+ return _read(_get_incident(session, incident.id))
+
+
+def resolve_quality_incident(
+ session: Session,
+ incident_id: str,
+ action: QualityIncidentAction,
+) -> QualityIncidentRead:
+ incident = _get_incident(session, incident_id)
+ if incident.status == "RESOLVED":
+ raise QualityIncidentTransitionError("Quality incident is already resolved.")
+
+ now = _utc_now()
+ incident.status = "RESOLVED"
+ incident.resolved_at = now
+ incident.resolution_note = action.note or "Resolved manually from SkyData Studio."
+ _event(
+ incident,
+ "RESOLVED",
+ actor=action.actor,
+ note=incident.resolution_note,
+ evidence_outcome=None,
+ )
+ session.commit()
+ session.refresh(incident)
+ return _read(_get_incident(session, incident.id))
diff --git a/apps/web/src/App.jsx b/apps/web/src/App.jsx
index 404d8cd..bc9a5b5 100644
--- a/apps/web/src/App.jsx
+++ b/apps/web/src/App.jsx
@@ -15,6 +15,7 @@ import DataModels from './pages/DataModels.jsx';
import SemanticLayer from './pages/SemanticLayer.jsx';
import DataQuality from './pages/DataQuality.jsx';
import DataContracts from './pages/DataContracts.jsx';
+import QualityIncidents from './pages/QualityIncidents.jsx';
import PlaceholderPage from './pages/PlaceholderPage.jsx';
const PAGE_META = {
@@ -28,8 +29,9 @@ const PAGE_META = {
'/orchestration/airflow': ['Apache Airflow', 'DAG, asset, scheduler, and executor observability'],
'/orchestration/runs': ['Pipeline Runs', 'Durable batch execution evidence'],
'/orchestration/backfills': ['Schedules & Backfills', 'Time, asset, and replay controls'],
- '/quality/checks': ['Data Quality', 'Tests, incidents, policies, and trust evidence'],
+ '/quality/checks': ['Data Quality', 'Tests, coverage, and trust evidence'],
'/quality/contracts': ['Contracts', 'Quality gates and consumer compatibility'],
+ '/quality/incidents': ['Quality Incidents', 'Durable failures, ownership, and remediation history'],
'/quality/lineage': ['Lineage', 'Upstream, downstream, and change impact'],
'/delivery/marts': ['Analytical Marts', 'Curated consumer-ready facts and dimensions'],
'/delivery/semantic': ['Semantic Layer', 'Governed metrics and business definitions'],
@@ -73,6 +75,7 @@ function App() {
} />
} />
} />
+ } />
} />
} />
} />
@@ -91,6 +94,7 @@ function App() {
'/delivery/semantic',
'/quality/checks',
'/quality/contracts',
+ '/quality/incidents',
].includes(path))
.map(([path, [title, subtitle]]) => (
Development
- Phase 7.2 quality contract gates
+ Phase 7.3 durable quality incidents
diff --git a/apps/web/src/pages/DataContracts.jsx b/apps/web/src/pages/DataContracts.jsx
index 4322491..a731d86 100644
--- a/apps/web/src/pages/DataContracts.jsx
+++ b/apps/web/src/pages/DataContracts.jsx
@@ -186,10 +186,10 @@ function DataContracts() {
-
BOUNDARY
Gate now; incidents next
▤
+
BOUNDARY
Gate established; incidents now durable
▤
- Phase 7.2 evaluates policy but does not persist incident lifecycle. Durable incidents,
- acknowledgement, remediation ownership, and historical SLO evidence remain later slices.
+ Phase 7.2 established the policy gate. Phase 7.3 now persists incident lifecycle,
+ acknowledgement, remediation ownership, recurrence, and evidence-driven resolution; historical SLOs remain later work.
Code as contractArtifact evidenceNo duplicated dbt authority
diff --git a/apps/web/src/pages/DataQuality.jsx b/apps/web/src/pages/DataQuality.jsx
index 125d4a8..ef34394 100644
--- a/apps/web/src/pages/DataQuality.jsx
+++ b/apps/web/src/pages/DataQuality.jsx
@@ -181,13 +181,13 @@ function DataQuality() {
-
BOUNDARY
Evidence first; incidents later
✓
+
BOUNDARY
Evidence first; incidents downstream
✓
- Phase 7.1 is observational. dbt remains the test-definition and execution authority;
- Studio projects the latest evidence. Persistent incidents, blocking policies, SLOs,
- reconciliation rules, and acknowledgements belong to later Phase 7 slices.
+ Phase 7.1 remains the observational evidence seam. Phase 7.2 consumes it as policy,
+ and Phase 7.3 now persists operational incidents downstream without changing dbt's
+ ownership of test definitions or execution.
-
One authorityLatest-run evidenceNo fake incidents
+
One test authorityLatest-run evidenceDownstream incident memory
+ 01 · DETECTOPENBLOCK, MISSING, or WARN contract evidence creates one durable incident per rule.
+ 02 · OWNACKNOWLEDGEDAn operator accepts responsibility with an identity and optional remediation note.
+ 03 · VERIFYRESOLVEDReturning PASS evidence auto-resolves; manual resolution remains auditable.
+ 04 · RECURREOPENEDA resolved rule that fails again reopens the same incident and increments occurrence history.
+
Incidents are earnedStudio persists incidents only from real non-passing contract outcomes; a clean contract creates nothing.
+
02
Lifecycle is durableOpen, acknowledgement, resolution, and recurrence survive process restarts in Studio PostgreSQL.
+
03
Recovery is evidence-drivenPASS evidence closes active incidents automatically, while recurring failures reopen the same durable record.
+
+
+
+
BOUNDARY
Policy detects; incidents remember
↺
+
+ dbt still owns test execution and source-controlled contracts still own consumer policy.
+ Studio owns only the operational lifecycle created from that evidence: who accepted the issue,
+ what happened next, when it cleared, and whether it came back.
+
+
No duplicate test authorityPersistent historyEvidence-driven closure
@@ -61,7 +61,7 @@ function StudioOverview() {
Platform API{apiState}FastAPI contract service
- Current Phase7.2quality contract gates + compatibility
+ Current Phase7.3durable quality incidents + remediationPipeline ProofREADYCurated mart materialization provenOrchestratorAirflow 3Batch and asset workflows
@@ -110,11 +110,11 @@ function StudioOverview() {
-
CURRENT IMPLEMENTATION
quality contract gate and consumer compatibility workbench
PHASE 7.2
+
CURRENT IMPLEMENTATION
durable quality incidents and remediation lifecycle
PHASE 7.3
-
01
Source-controlled quality gateDeclare required evidence selectors beside the repository instead of copying dbt metadata into another store.
-
02
Consumer readinessEvaluate the latest dbt quality evidence against a versioned mart contract before presenting it as compliant.
-
03
Compatibility in one placeKeep SkyCommand consumer contracts and Studio-owned quality policy visible on the same workbench.
+
01
Evidence-driven incident creationPersist only real warning, blocking, or missing contract outcomes; passing evidence creates no fake incident.
+
02
Durable remediation lifecyclePreserve acknowledgement, operator ownership, resolution notes, timestamps, and event history in Studio PostgreSQL.
+
03
Automatic recovery and recurrenceReturning PASS evidence resolves active incidents; a later failure reopens the same durable record as another occurrence.
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 49f763b..93353a5 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -1047,3 +1047,57 @@ a { color: inherit; }
.contracts-intro { align-items: stretch; flex-direction: column; }
.contracts-metric-grid, .contract-definition-grid, .contract-rule-grid, .consumer-contract-grid { grid-template-columns: 1fr; }
}
+
+/* Phase 7.3 — durable quality incidents and remediation lifecycle */
+.incidents-intro { display: flex; align-items: flex-end; justify-content: space-between; gap: 1.4rem; }
+.incident-actions-row { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
+.incidents-metric-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); }
+.incident-reconcile-note { border: 1px solid rgba(80,231,207,0.18); border-radius: 10px; padding: 10px 13px; color: var(--mint); background: rgba(66,217,198,0.06); font-size: 0.68rem; }
+.incident-lifecycle-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
+.incident-lifecycle-grid article { display: grid; gap: 7px; min-height: 122px; padding: 14px; border: 1px solid rgba(154,131,255,0.17); border-radius: 11px; background: linear-gradient(145deg,rgba(5,17,25,0.88),rgba(17,16,38,0.52)); }
+.incident-lifecycle-grid span { color: var(--subtle); font-size: 0.58rem; font-weight: 850; letter-spacing: 0.08em; }
+.incident-lifecycle-grid strong { color: var(--mint); font-size: 0.9rem; }
+.incident-lifecycle-grid small { color: var(--muted); font-size: 0.66rem; line-height: 1.5; }
+.incident-table { width: 100%; min-width: 1250px; border-collapse: collapse; }
+.incident-table th { padding: 0.7rem 0.72rem; color: var(--subtle); font-size: 0.6rem; letter-spacing: 0.08em; text-align: left; text-transform: uppercase; border-bottom: 1px solid rgba(111,190,187,0.15); }
+.incident-table td { padding: 0.75rem 0.72rem; color: var(--soft); font-size: 0.7rem; vertical-align: top; border-bottom: 1px solid rgba(111,190,187,0.08); }
+.incident-table td strong, .incident-table td small { display: block; }
+.incident-table td strong { color: var(--text); }
+.incident-table td small { max-width: 250px; margin-top: 4px; overflow: hidden; color: var(--muted); font-size: 0.6rem; text-overflow: ellipsis; white-space: nowrap; }
+.incident-severity { display: inline-flex; border-radius: 999px; padding: 0.22rem 0.42rem; font-size: 0.58rem; font-weight: 850; letter-spacing: 0.06em; }
+.severity-blocking { border: 1px solid rgba(255,99,99,0.26); color: #ffb1b1; background: rgba(255,99,99,0.07); }
+.severity-warning { border: 1px solid rgba(255,199,92,0.28); color: #ffd991; background: rgba(255,199,92,0.07); }
+.incident-empty { padding: 2rem !important; text-align: center; }
+.incident-empty strong, .incident-empty small { display: block; }
+.incident-empty strong { color: var(--mint) !important; font-size: 0.82rem; }
+.incident-empty small { margin-top: 7px !important; max-width: none !important; color: var(--muted) !important; white-space: normal !important; }
+.incident-detail-panel { display: grid; gap: 16px; }
+.incident-detail-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
+.incident-detail-grid article { display: grid; gap: 5px; padding: 12px; border: 1px solid rgba(80,231,207,0.13); border-radius: 10px; background: rgba(3,14,22,0.58); }
+.incident-detail-grid span, .incident-action-form label > span { color: var(--muted); font-size: 0.61rem; font-weight: 850; letter-spacing: 0.08em; text-transform: uppercase; }
+.incident-detail-grid strong { color: var(--text); font-size: 0.72rem; overflow-wrap: anywhere; }
+.incident-detail-grid small { color: var(--subtle); font-size: 0.62rem; line-height: 1.45; }
+.incident-workflow-grid { display: grid; grid-template-columns: minmax(280px,0.8fr) minmax(0,1.2fr); gap: 14px; }
+.incident-action-form { display: grid; align-content: start; gap: 12px; padding: 14px; border: 1px solid rgba(154,131,255,0.17); border-radius: 11px; background: rgba(8,15,31,0.56); }
+.incident-action-form label { display: grid; gap: 6px; }
+.incident-action-form input, .incident-action-form textarea { width: 100%; border: 1px solid rgba(111,190,187,0.18); border-radius: 8px; padding: 0.72rem 0.78rem; color: var(--text); background: rgba(2,13,21,0.72); font: inherit; }
+.incident-action-form textarea { resize: vertical; }
+.incident-action-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
+.incident-form-note { color: var(--muted); font-size: 0.62rem; line-height: 1.5; }
+.incident-history { display: grid; align-content: start; gap: 8px; }
+.incident-history > article { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 6px 12px; padding: 11px 12px; border: 1px solid rgba(111,190,187,0.12); border-radius: 9px; background: rgba(3,14,22,0.5); }
+.incident-history > article > div { display: grid; gap: 3px; }
+.incident-history strong { color: var(--text); font-size: 0.7rem; }
+.incident-history small, .incident-history span { color: var(--muted); font-size: 0.6rem; }
+.incident-history p { grid-column: 1 / -1; margin: 0; color: var(--soft); font-size: 0.65rem; line-height: 1.5; }
+
+@media (max-width: 1180px) {
+ .incidents-metric-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+ .incident-lifecycle-grid, .incident-detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+
+@media (max-width: 820px) {
+ .incidents-intro { align-items: stretch; flex-direction: column; }
+ .incident-actions-row { justify-content: flex-start; }
+ .incidents-metric-grid, .incident-lifecycle-grid, .incident-detail-grid, .incident-workflow-grid { grid-template-columns: 1fr; }
+}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 493a108..640dc34 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -95,7 +95,38 @@ Studio quality-contract evaluator ◀── Phase 7.1 latest dbt evidence
└── PENDING
```
-The contract is versioned with application code and references target model, quality dimension, test kind, and column instead of dbt-generated test unique IDs. dbt still owns test definitions and execution. SkyCommand still owns its separate ingestion consumer contracts. The `/quality/contracts` workbench presents both boundaries together for visibility without collapsing their ownership. Durable incidents, acknowledgements, remediation ownership, reconciliation state, and SLO history require Studio-owned runtime evidence and remain later Phase 7 slices.
+The contract is versioned with application code and references target model, quality dimension, test kind, and column instead of dbt-generated test unique IDs. dbt still owns test definitions and execution. SkyCommand still owns its separate ingestion consumer contracts. The `/quality/contracts` workbench presents both boundaries together for visibility without collapsing their ownership. Phase 7.3 adds Studio-owned operational incident memory downstream of this policy seam; historical SLO calculation remains later work.
+
+
+### Durable quality incident boundary
+
+```text
+dbt test evidence + source-controlled quality contract
+ │
+ ▼
+ Phase 7.2 gate evaluator
+ │ rule outcome
+ ┌────────────┼─────────────┐
+ │ │ │
+ PASS WARN BLOCK / MISSING
+ │ │ │
+ │ └──────┬──────┘
+ │ ▼
+ │ quality_incident
+ │ │
+ │ quality_incident_event
+ │ │
+ │ OPEN → ACKNOWLEDGED
+ │ │
+ └───────────────→ RESOLVED
+ │
+ failure returns
+ ▼
+ REOPENED
+```
+
+The incident key is stable at `contract_code + rule_code`, so repeated reconciliation does not create duplicates. A clean PASS does not create an incident. An active incident is resolved automatically when PASS evidence returns; a manually resolved incident is reopened if the same rule is still failing on the next reconciliation. This keeps policy, test execution, and operational memory as three distinct authorities.
+
## Product blueprint and mapping boundary
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index afc8556..1232007 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -493,7 +493,7 @@ Closure evidence:
## Phase 7.2 — Quality Contract Gate and Consumer Compatibility Workbench
-**Status:** In progress.
+**Status:** Complete. Source-controlled gate evaluation, consumer compatibility, workbench proof, React lint cleanup, and local validation are green.
Scope:
@@ -505,10 +505,35 @@ Scope:
- replace the Contracts placeholder with a workbench that shows both Studio-owned quality policy and existing SkyCommand consumer contract compatibility;
- keep durable incidents, acknowledgement/remediation ownership, historical SLOs, and reconciliation state outside this slice.
-Acceptance proof:
+Closure evidence:
-- the quality-contract API reports the Federal Funds Rate contract as `COMPLIANT`, with 5/5 required rules satisfied and a 100% pass rate against the current 14/14 dbt quality evidence;
+- the live quality-contract API reports `COMPLIANT`, 5/5 required rules satisfied, 100% pass rate, 0 blocking rules, 0 missing rules, and READY/TRUSTED underlying dbt evidence;
- the five rules resolve to MART observation-date completeness, rate completeness, observation-date uniqueness, rate-direction validity, and the singular rate-reasonableness assertion;
-- the Contracts workbench displays the quality gate plus all five SkyCommand consumer boundary contracts without merging ownership between the two systems;
-- missing latest dbt evidence returns `PENDING`, while a missing or non-passing required selector blocks the contract under `BLOCK` enforcement;
-- local validation remains green with 64 pytest tests plus Ruff, mypy, npm audit, ESLint, and Vite build.
+- the Contracts workbench displays all five quality rules as PASS and all five SkyCommand consumer boundary contracts as COMPATIBLE without merging ownership between the two systems;
+- missing latest dbt evidence remains `PENDING`, while a missing or non-passing required selector blocks the contract under `BLOCK` enforcement;
+- the React initial-fetch pattern was aligned with the rest of Studio so `react-hooks/set-state-in-effect` remains green;
+- final local validation passes Ruff, mypy across 51 source files, 64 pytest tests, npm audit with zero vulnerabilities, ESLint, and Vite production build.
+
+## Phase 7.3 — Durable Quality Incidents and Remediation Lifecycle
+
+**Status:** In progress.
+
+Scope:
+
+- add Studio-owned `quality_incident` and `quality_incident_event` persistence while keeping dbt and source-controlled quality contracts as the upstream evidence/policy authorities;
+- reconcile `WARN`, `BLOCK`, and `MISSING` contract-rule outcomes into one durable incident per contract rule;
+- preserve OPEN, ACKNOWLEDGED, RESOLVED, and REOPENED lifecycle evidence with operator identity, notes, timestamps, and occurrence count;
+- auto-resolve active incidents when the corresponding rule returns to PASS;
+- reopen a resolved incident when failing evidence returns, rather than creating duplicate incident rows;
+- expose summary, reconciliation, acknowledgement, and manual-resolution APIs under `/api/v1/quality/incidents`;
+- add a Quality Incidents workbench while explicitly creating no fake incident when the current contract remains clean;
+- keep historical SLO calculation, notification routing, and cross-product lineage impact outside this slice.
+
+Acceptance proof:
+
+- re-running `uv run python scripts/bootstrap_metadata.py` creates the additive incident and event tables in existing Studio PostgreSQL;
+- reconciling the current COMPLIANT 5/5 Federal Funds Rate contract returns zero active incidents and creates no synthetic failure record;
+- backend lifecycle tests prove BLOCK → OPEN, operator acknowledgement, manual/PASS-driven resolution, and recurrence → REOPENED with occurrence history;
+- the Quality Incidents workbench shows current contract state, active/blocking/resolved totals, the durable register, lifecycle history, and acknowledgement/resolution controls;
+- local validation remains green with 68 pytest tests plus Ruff, mypy, npm audit, ESLint, and Vite build.
+
diff --git a/docs/SkyData_RepoMap.md b/docs/SkyData_RepoMap.md
index eb6407f..09f603c 100644
--- a/docs/SkyData_RepoMap.md
+++ b/docs/SkyData_RepoMap.md
@@ -47,7 +47,8 @@ SkyDataStudio/
│ │ ├── models/
│ │ │ ├── __init__.py
│ │ │ ├── metadata.py
-│ │ │ └── pipeline.py
+│ │ │ ├── pipeline.py
+│ │ │ └── quality.py
│ │ ├── schemas/
│ │ │ ├── __init__.py
│ │ │ ├── airflow.py
@@ -68,7 +69,8 @@ SkyDataStudio/
│ │ ├── metadata_registry.py
│ │ ├── pipeline_execution.py
│ │ ├── pipeline_registry.py
-│ │ └── quality_contracts.py
+│ │ ├── quality_contracts.py
+│ │ └── quality_incidents.py
│ └── web/
│ ├── eslint.config.js
│ ├── index.html
@@ -94,6 +96,7 @@ SkyDataStudio/
│ │ ├── PipelineRuns.jsx
│ │ ├── Pipelines.jsx
│ │ ├── PlaceholderPage.jsx
+│ │ ├── QualityIncidents.jsx
│ │ ├── SchedulesBackfills.jsx
│ │ ├── SemanticLayer.jsx
│ │ ├── SourceMappings.jsx
@@ -120,7 +123,8 @@ SkyDataStudio/
│ │ ├── 0001_metadata_registry.sql
│ │ ├── 0002_source_target_mapping.sql
│ │ ├── 0003_pipeline_definition.sql
-│ │ └── 0004_pipeline_execution.sql
+│ │ ├── 0004_pipeline_execution.sql
+│ │ └── 0005_quality_incident.sql
│ └── dbt/
│ └── Dockerfile
├── orchestration/
@@ -157,6 +161,7 @@ SkyDataStudio/
│ ├── test_platform.py
│ ├── test_quality_contracts.py
│ ├── test_quality_gate_contracts.py
+│ ├── test_quality_incidents.py
│ ├── test_quality.py
│ ├── test_skycommand_client.py
│ ├── test_skycommand_contracts.py
diff --git a/docs/repository-map.txt b/docs/repository-map.txt
index 04c68da..5a17121 100644
--- a/docs/repository-map.txt
+++ b/docs/repository-map.txt
@@ -40,7 +40,8 @@ SkyDataStudio
│ │ ├── models
│ │ │ ├── __init__.py
│ │ │ ├── metadata.py
-│ │ │ └── pipeline.py
+│ │ │ ├── pipeline.py
+│ │ │ └── quality.py
│ │ ├── schemas
│ │ │ ├── __init__.py
│ │ │ ├── airflow.py
@@ -61,7 +62,8 @@ SkyDataStudio
│ │ │ ├── metadata_registry.py
│ │ │ ├── pipeline_execution.py
│ │ │ ├── pipeline_registry.py
-│ │ │ └── quality_contracts.py
+│ │ │ ├── quality_contracts.py
+│ │ │ └── quality_incidents.py
│ │ ├── __init__.py
│ │ └── main.py
│ └── web
@@ -81,6 +83,7 @@ SkyDataStudio
│ │ │ ├── PipelineRuns.jsx
│ │ │ ├── Pipelines.jsx
│ │ │ ├── PlaceholderPage.jsx
+│ │ │ ├── QualityIncidents.jsx
│ │ │ ├── SchedulesBackfills.jsx
│ │ │ ├── SemanticLayer.jsx
│ │ │ ├── SourceMappings.jsx
@@ -114,7 +117,8 @@ SkyDataStudio
│ │ ├── 0001_metadata_registry.sql
│ │ ├── 0002_source_target_mapping.sql
│ │ ├── 0003_pipeline_definition.sql
-│ │ └── 0004_pipeline_execution.sql
+│ │ ├── 0004_pipeline_execution.sql
+│ │ └── 0005_quality_incident.sql
│ ├── dbt
│ │ └── Dockerfile
│ └── docker-compose.yml
@@ -153,6 +157,7 @@ SkyDataStudio
│ ├── test_quality.py
│ ├── test_quality_contracts.py
│ ├── test_quality_gate_contracts.py
+│ ├── test_quality_incidents.py
│ ├── test_skycommand_client.py
│ ├── test_skycommand_contracts.py
│ └── test_validation_environment.py
diff --git a/infra/db/migrations/0005_quality_incident.sql b/infra/db/migrations/0005_quality_incident.sql
new file mode 100644
index 0000000..79f1e69
--- /dev/null
+++ b/infra/db/migrations/0005_quality_incident.sql
@@ -0,0 +1,56 @@
+CREATE TABLE IF NOT EXISTS quality_incident (
+ id VARCHAR(36) PRIMARY KEY,
+ incident_key VARCHAR(320) NOT NULL UNIQUE,
+ contract_code VARCHAR(160) NOT NULL,
+ contract_version VARCHAR(40) NOT NULL,
+ rule_code VARCHAR(160) NOT NULL,
+ rule_label VARCHAR(255) NOT NULL,
+ target_name VARCHAR(255) NOT NULL,
+ layer VARCHAR(40) NOT NULL,
+ quality_dimension VARCHAR(60) NOT NULL,
+ severity VARCHAR(40) NOT NULL,
+ status VARCHAR(40) NOT NULL DEFAULT 'OPEN',
+ evidence_outcome VARCHAR(40) NOT NULL,
+ matched_check_name VARCHAR(500),
+ matched_status VARCHAR(40),
+ message TEXT NOT NULL,
+ occurrence_count INTEGER NOT NULL DEFAULT 1,
+ first_detected_at TIMESTAMPTZ NOT NULL,
+ last_detected_at TIMESTAMPTZ NOT NULL,
+ acknowledged_at TIMESTAMPTZ,
+ acknowledged_by VARCHAR(160),
+ resolved_at TIMESTAMPTZ,
+ resolution_note TEXT,
+ created_at TIMESTAMPTZ NOT NULL,
+ updated_at TIMESTAMPTZ NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS ix_quality_incident_contract_code
+ ON quality_incident (contract_code);
+CREATE INDEX IF NOT EXISTS ix_quality_incident_rule_code
+ ON quality_incident (rule_code);
+CREATE INDEX IF NOT EXISTS ix_quality_incident_target_name
+ ON quality_incident (target_name);
+CREATE INDEX IF NOT EXISTS ix_quality_incident_layer
+ ON quality_incident (layer);
+CREATE INDEX IF NOT EXISTS ix_quality_incident_quality_dimension
+ ON quality_incident (quality_dimension);
+CREATE INDEX IF NOT EXISTS ix_quality_incident_severity
+ ON quality_incident (severity);
+CREATE INDEX IF NOT EXISTS ix_quality_incident_status
+ ON quality_incident (status);
+
+CREATE TABLE IF NOT EXISTS quality_incident_event (
+ id VARCHAR(36) PRIMARY KEY,
+ incident_id VARCHAR(36) NOT NULL REFERENCES quality_incident(id) ON DELETE CASCADE,
+ event_type VARCHAR(40) NOT NULL,
+ actor VARCHAR(160),
+ note TEXT,
+ evidence_outcome VARCHAR(40),
+ created_at TIMESTAMPTZ NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS ix_quality_incident_event_incident_id
+ ON quality_incident_event (incident_id);
+CREATE INDEX IF NOT EXISTS ix_quality_incident_event_event_type
+ ON quality_incident_event (event_type);
diff --git a/tests/test_platform.py b/tests/test_platform.py
index 8035d9b..c90c5f1 100644
--- a/tests/test_platform.py
+++ b/tests/test_platform.py
@@ -13,7 +13,7 @@ def test_platform_summary_exposes_product_boundary() -> None:
assert payload["theme"] == "Aurora Foundry"
assert "SkyCommand" in payload["boundary"]
assert payload["current_phase"] == (
- "Phase 7.2 — Quality Contract Gate and Consumer Compatibility Workbench"
+ "Phase 7.3 — Durable Quality Incidents and Remediation Lifecycle"
)
assert {item["code"] for item in payload["capabilities"]} >= {
"AIRFLOW",
diff --git a/tests/test_quality_incidents.py b/tests/test_quality_incidents.py
new file mode 100644
index 0000000..989256c
--- /dev/null
+++ b/tests/test_quality_incidents.py
@@ -0,0 +1,154 @@
+from collections.abc import Generator
+
+import pytest
+from skydata_studio.models.quality import QualityIncident, QualityIncidentEvent
+from skydata_studio.schemas.quality import (
+ QualityContractRuleEvaluation,
+ QualityContractSummary,
+ QualityIncidentAction,
+)
+from skydata_studio.services.quality_incidents import (
+ QualityIncidentTransitionError,
+ acknowledge_quality_incident,
+ quality_incident_summary,
+ reconcile_quality_incidents,
+ resolve_quality_incident,
+)
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session
+
+
+@pytest.fixture
+def session() -> Generator[Session, None, None]:
+ engine = create_engine("sqlite+pysqlite:///:memory:")
+ QualityIncident.__table__.create(engine)
+ QualityIncidentEvent.__table__.create(engine)
+ with Session(engine) as db_session:
+ yield db_session
+
+
+def _contract(outcome: str) -> QualityContractSummary:
+ matched_status = "PASS" if outcome == "PASS" else "FAIL"
+ return QualityContractSummary.model_validate(
+ {
+ "contract_code": "FED_FUNDS_RATE_DAILY_QUALITY",
+ "contract_version": "1.0.0",
+ "contract_name": "Federal Funds Rate Daily Quality Contract",
+ "description": "Proof contract.",
+ "target_name": "fct_fed_funds_rate_daily",
+ "layer": "MART",
+ "enforcement_mode": "BLOCK",
+ "artifact_status": "READY",
+ "evidence_trust_posture": "TRUSTED" if outcome == "PASS" else "BLOCKED",
+ "contract_status": "COMPLIANT" if outcome == "PASS" else "BLOCKED",
+ "minimum_pass_rate": 1.0,
+ "pass_rate": 1.0 if outcome == "PASS" else 0.0,
+ "required_rule_count": 1,
+ "satisfied_rule_count": 1 if outcome == "PASS" else 0,
+ "warning_rule_count": 1 if outcome == "WARN" else 0,
+ "blocking_rule_count": 0 if outcome in {"PASS", "WARN"} else 1,
+ "missing_rule_count": 1 if outcome == "MISSING" else 0,
+ "source_path": "contracts/quality/fed_funds_rate_daily.v1.json",
+ "rules": [
+ QualityContractRuleEvaluation.model_validate(
+ {
+ "code": "RATE_REQUIRED",
+ "label": "Rate is present",
+ "quality_dimension": "COMPLETENESS",
+ "test_kind": "GENERIC",
+ "column_name": "rate",
+ "required_status": "PASS",
+ "outcome": outcome,
+ "matched_check_name": "not_null_fct_fed_funds_rate_daily_rate",
+ "matched_status": matched_status,
+ "matched_severity": "ERROR",
+ "message": "Synthetic lifecycle proof.",
+ }
+ )
+ ],
+ }
+ )
+
+
+def test_reconcile_opens_durable_incident_for_blocking_rule(session: Session) -> None:
+ result = reconcile_quality_incidents(session, contract=_contract("BLOCK"))
+
+ assert result.created_count == 1
+ assert result.summary.active_count == 1
+ incident = result.summary.incidents[0]
+ assert incident.status == "OPEN"
+ assert incident.severity == "BLOCKING"
+ assert incident.occurrence_count == 1
+ assert [event.event_type for event in incident.events] == ["OPENED"]
+
+
+def test_incident_can_be_acknowledged_and_manually_resolved(session: Session) -> None:
+ opened = reconcile_quality_incidents(session, contract=_contract("BLOCK"))
+ incident_id = opened.summary.incidents[0].id
+
+ acknowledged = acknowledge_quality_incident(
+ session,
+ incident_id,
+ QualityIncidentAction(actor="paul", note="Investigating upstream evidence."),
+ )
+ resolved = resolve_quality_incident(
+ session,
+ incident_id,
+ QualityIncidentAction(actor="paul", note="Remediation verified."),
+ )
+
+ assert acknowledged.status == "ACKNOWLEDGED"
+ assert acknowledged.acknowledged_by == "paul"
+ assert resolved.status == "RESOLVED"
+ assert resolved.resolution_note == "Remediation verified."
+ assert [event.event_type for event in resolved.events] == [
+ "OPENED",
+ "ACKNOWLEDGED",
+ "RESOLVED",
+ ]
+
+
+def test_reconcile_auto_resolves_incident_when_contract_returns_to_pass(session: Session) -> None:
+ reconcile_quality_incidents(session, contract=_contract("BLOCK"))
+
+ result = reconcile_quality_incidents(session, contract=_contract("PASS"))
+
+ assert result.resolved_count == 1
+ assert result.summary.active_count == 0
+ incident = result.summary.incidents[0]
+ assert incident.status == "RESOLVED"
+ assert incident.resolution_note == "Latest quality contract evidence returned to PASS."
+ assert incident.events[-1].event_type == "RESOLVED"
+ assert incident.events[-1].actor == "SYSTEM"
+
+
+def test_failed_evidence_reopens_resolved_incident_as_new_occurrence(session: Session) -> None:
+ opened = reconcile_quality_incidents(session, contract=_contract("BLOCK"))
+ incident_id = opened.summary.incidents[0].id
+ resolve_quality_incident(
+ session,
+ incident_id,
+ QualityIncidentAction(actor="operator", note="Temporary remediation."),
+ )
+
+ result = reconcile_quality_incidents(session, contract=_contract("BLOCK"))
+ summary = quality_incident_summary(session, contract=_contract("BLOCK"))
+
+ assert result.reopened_count == 1
+ assert summary.active_count == 1
+ incident = summary.incidents[0]
+ assert incident.status == "OPEN"
+ assert incident.occurrence_count == 2
+ assert incident.events[-1].event_type == "REOPENED"
+
+ acknowledge_quality_incident(
+ session,
+ incident_id,
+ QualityIncidentAction(actor="operator", note="Owned again."),
+ )
+ with pytest.raises(QualityIncidentTransitionError):
+ acknowledge_quality_incident(
+ session,
+ incident_id,
+ QualityIncidentAction(actor="operator", note="Duplicate acknowledgement."),
+ )