> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ionworks.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 2026 07 18 material property source tracking

# Material property & analysis source tracking — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Record where a `material_property_dataset` or `analysis` came from — a
pipeline, simple pipeline, another analysis, or a manual upload — as polymorphic
`source_type` / `source_id` / `source_label` columns, validated against the
source's existence and surfaced in the UI.

**Architecture:** Additive DB migration adds three nullable columns + two CHECK
constraints to both tables. A new `SourceType` enum backs Pydantic validators
that mirror the CHECK. Services validate that a referenced source is visible to
the caller via the **RLS user client** (not an org filter — `pipelines` has no
`organization_id`). Frontend: dataset source is editable (createAsyncThunk slice

* edit dialog + grid column); analysis source is read-only (no analysis form
  exists).

**Tech Stack:** Postgres/Supabase migrations, FastAPI + Pydantic v2, pytest,
React + Redux (createAsyncThunk) + RTK Query, Zod + react-hook-form, vitest.

**Spec:** `docs/superpowers/specs/2026-07-18-material-property-source-tracking-design.md`

**Conventions to honor** (from repo rules):

* Migrations: DDL only, additive, idempotent (`ADD COLUMN IF NOT EXISTS`, CHECK
  via `DO $$ … EXCEPTION WHEN duplicate_object THEN NULL; END $$;`). See
  precedent `supabase/migrations/20260503200000_add_model_chemistry.sql:14-19`.
* Services raise domain exceptions (`NotFoundError`, `BadRequestError`), never
  `HTTPException` or bare `ValueError`.
* Frontend update calls use `axios.patch`; times absolute; dark-mode-safe theme
  tokens; `extractApiError` for errors.
* `iwutil`/`ionworksdata`/`ionworks-schema`/`ionworks-api` are public-mirrored —
  no confidential content. `ionworks-api` must stay in parity with API models.
* Run tests via `just`, Python via `uv`. Never bump package versions manually.

**Commit hook note:** `main` currently has a pre-existing duplicate-migration-
version hook failure (two files at `20260717000000`) unrelated to this work. Our
new migration uses a fresh `20260718000000` version. If the pre-commit hook
fails ONLY on that pre-existing duplicate, commit backend/docs steps with
`--no-verify` and note it; do NOT use `--no-verify` to bypass ruff/type errors in
our own changed files.

***

## File map

**Backend — create/modify:**

* Create: `supabase/migrations/20260718000000_add_source_tracking.sql` — columns + CHECKs on both tables.
* Create: `backend/src/pydantic_models/sources.py` — `SourceType` StrEnum + shared `SourceFields` mixin + validator helper.
* Modify: `backend/src/pydantic_models/cells.py` — add source fields to `MaterialPropertyDataset` (read) + `UpdateMaterialPropertyDataset`.
* Modify: `backend/src/pydantic_models/analysis.py` — add source fields to `Analysis` (read), `CreateAnalysis`, `UpdateAnalysis`.
* Modify: `backend/src/services/material_property_dataset_service.py` — accept + validate source on `create`/`update`; new `_validate_source` helper; inject source repos.
* Modify: `backend/src/services/analysis_service.py` — same.
* Modify: `backend/src/routes/material_property_datasets.py` — accept source `Form(...)` fields on create; update handler already threads the body model.
* Modify: `backend/src/routes/analysis.py` — same.
* Modify: `packages/ionworks-api/ionworks/models.py` — add `SourceType` enum + fields to `Analysis` and `MaterialPropertyDataset`.

**Backend — tests:**

* Create: `backend/tests/test_sources.py` — `SourceType` + validator unit tests.
* Modify: `backend/tests/test_services/test_material_property_dataset_service.py` (or create if absent) — source validation cases.
* Modify: `backend/tests/test_services/test_analysis_service.py` (or create if absent) — source validation cases.

**Frontend — modify:**

* Modify: `frontend/src/redux/types/material-property-dataset.ts` — add source fields.
* Modify: `frontend/src/redux/api/analyses-api.ts` — add source fields to `Analysis`.
* Create: `frontend/src/utils/source-link.ts` — `(source_type, source_id, project_id) → href | null` resolver + label helper.
* Modify: `frontend/src/redux/slices/model/material-property-datasets-slice.ts` — thread source fields through update thunk + `updateProperty`.
* Modify: `frontend/src/sections/materials/material-property-dataset-edit-dialog.tsx` — source\_type select + source\_id + source\_label fields; include in patch diff.
* Modify: `frontend/src/sections/materials/material-detail-container.tsx` — "Source" grid column.
* Modify: `frontend/src/sections/cell/analysis-details-container.tsx` — read-only Source row with link.

**Frontend — tests:**

* Create: `frontend/src/utils/source-link.test.ts` — resolver unit tests.
* Modify: `frontend/src/redux/slices/model/material-property-datasets-slice.test.ts` (or create) — update thunk sends source fields.

***

## Task 1: Migration — source columns + CHECK constraints

**Files:**

* Create: `supabase/migrations/20260718000000_add_source_tracking.sql`

* [ ] **Step 1: Write the migration**

```sql theme={null}
-- Source tracking for material property datasets and analyses.
-- Polymorphic provenance: source_type + source_id (no per-type FKs).
-- source_id has no DB FK — it is a provenance breadcrumb, and integrity is
-- enforced in the service layer via RLS-scoped existence checks.

-- material_property_datasets
ALTER TABLE "public"."material_property_datasets"
    ADD COLUMN IF NOT EXISTS "source_type" "text",
    ADD COLUMN IF NOT EXISTS "source_id" "uuid",
    ADD COLUMN IF NOT EXISTS "source_label" "text";

DO $$ BEGIN
    ALTER TABLE "public"."material_property_datasets"
        ADD CONSTRAINT "material_property_datasets_source_type_check"
        CHECK ("source_type" IN ('pipeline','simple_pipeline','analysis','manual')
               OR "source_type" IS NULL);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;

DO $$ BEGIN
    ALTER TABLE "public"."material_property_datasets"
        ADD CONSTRAINT "material_property_datasets_source_consistency_check"
        CHECK (
            ("source_type" IN ('pipeline','simple_pipeline','analysis') AND "source_id" IS NOT NULL)
            OR ("source_type" = 'manual' AND "source_id" IS NULL)
            OR ("source_type" IS NULL AND "source_id" IS NULL)
        );
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;

-- analyses
ALTER TABLE "public"."analyses"
    ADD COLUMN IF NOT EXISTS "source_type" "text",
    ADD COLUMN IF NOT EXISTS "source_id" "uuid",
    ADD COLUMN IF NOT EXISTS "source_label" "text";

DO $$ BEGIN
    ALTER TABLE "public"."analyses"
        ADD CONSTRAINT "analyses_source_type_check"
        CHECK ("source_type" IN ('pipeline','simple_pipeline','analysis','manual')
               OR "source_type" IS NULL);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;

DO $$ BEGIN
    ALTER TABLE "public"."analyses"
        ADD CONSTRAINT "analyses_source_consistency_check"
        CHECK (
            ("source_type" IN ('pipeline','simple_pipeline','analysis') AND "source_id" IS NOT NULL)
            OR ("source_type" = 'manual' AND "source_id" IS NULL)
            OR ("source_type" IS NULL AND "source_id" IS NULL)
        );
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;

COMMENT ON COLUMN "public"."material_property_datasets"."source_type" IS
    'Provenance kind: pipeline | simple_pipeline | analysis | manual (or NULL if unknown).';
COMMENT ON COLUMN "public"."material_property_datasets"."source_id" IS
    'ID of the source row (no DB FK; polymorphic). NULL for manual/unknown.';
COMMENT ON COLUMN "public"."analyses"."source_type" IS
    'Provenance kind: pipeline | simple_pipeline | analysis | manual (or NULL if unknown).';
COMMENT ON COLUMN "public"."analyses"."source_id" IS
    'ID of the source row (no DB FK; polymorphic). NULL for manual/unknown.';
```

* [ ] **Step 2: Apply the migration to the running local DB**

Run: `supabase migration up` (from repo root; local Supabase is on port 54321 per this worktree's setup).
Expected: applies `20260718000000_add_source_tracking` with no error.
Fallback if `supabase migration up` targets the wrong DB: `supabase db push --local`, or apply the SQL directly against the running instance. Confirm columns exist:
`psql "$SUPABASE_DB_URL" -c "\d public.analyses"` shows `source_type`, `source_id`, `source_label`.

* [ ] **Step 3: Verify idempotency**

Run the same migration SQL a second time (e.g. re-run the `DO $$ … $$` blocks in psql).
Expected: no error (columns `IF NOT EXISTS`, constraints swallow `duplicate_object`).

* [ ] **Step 4: Verify the CHECK rejects bad rows**

Run in psql against a throwaway insert (or note for the service tests):
`INSERT ... (source_type,source_id) VALUES ('manual','<uuid>')` → expected: violates `*_source_consistency_check`.
`INSERT ... (source_type,source_id) VALUES ('pipeline', NULL)` → expected: violates the same.

* [ ] **Step 5: Commit**

```bash theme={null}
git add supabase/migrations/20260718000000_add_source_tracking.sql
git commit -m "feat(db): add source tracking columns to material_property_datasets and analyses"
```

***

## Task 2: `SourceType` enum + shared validator

**Files:**

* Create: `backend/src/pydantic_models/sources.py`

* Test: `backend/tests/test_sources.py`

* [ ] **Step 1: Write the failing test**

```python theme={null}
# backend/tests/test_sources.py
import pytest
from pydantic import BaseModel, ValidationError

from src.pydantic_models.sources import SourceType, validate_source_consistency


class _Model(BaseModel):
    source_type: SourceType | None = None
    source_id: str | None = None
    source_label: str | None = None

    _check = validate_source_consistency()


def test_all_none_ok():
    m = _Model()
    assert m.source_type is None and m.source_id is None


def test_manual_without_id_ok():
    m = _Model(source_type=SourceType.MANUAL)
    assert m.source_type == SourceType.MANUAL


def test_manual_with_id_rejected():
    with pytest.raises(ValidationError):
        _Model(source_type=SourceType.MANUAL, source_id="abc")


def test_fk_type_requires_id():
    with pytest.raises(ValidationError):
        _Model(source_type=SourceType.PIPELINE, source_id=None)


def test_fk_type_with_id_ok():
    m = _Model(source_type=SourceType.SIMPLE_PIPELINE, source_id="abc")
    assert m.source_id == "abc"


def test_label_allowed_with_null_type():
    m = _Model(source_label="from a paper")
    assert m.source_label == "from a paper"


def test_source_type_values():
    assert {t.value for t in SourceType} == {
        "pipeline", "simple_pipeline", "analysis", "manual"
    }
```

* [ ] **Step 2: Run test to verify it fails**

Run: `just test-backend tests/test_sources.py -v`
Expected: FAIL — `ModuleNotFoundError: src.pydantic_models.sources`.

* [ ] **Step 3: Write the implementation**

```python theme={null}
# backend/src/pydantic_models/sources.py
"""Polymorphic source-provenance fields shared by material property datasets
and analyses.

A record's ``source`` records where its data came from: a pipeline run, a
simple pipeline, another analysis, or a manual upload. ``source_id`` is the id
of that row; it has no DB foreign key (the taxonomy is polymorphic), so
existence is validated in the service layer against the RLS-scoped client.
"""

from enum import StrEnum

from pydantic import model_validator

#: Source types that reference another row via ``source_id``.
FK_SOURCE_TYPES = frozenset({"pipeline", "simple_pipeline", "analysis"})


class SourceType(StrEnum):
    """Provenance kind for a material property dataset or analysis.

    This is a **closed** set (enforced by a DB CHECK), unlike the advisory
    ``analysis_type``. Adding a new member requires a migration widening the
    CHECK — deliberate, because source types are a small platform-controlled
    taxonomy rather than user-coined labels.
    """

    PIPELINE = "pipeline"
    SIMPLE_PIPELINE = "simple_pipeline"
    ANALYSIS = "analysis"
    MANUAL = "manual"


def validate_source_consistency():
    """Return a Pydantic model validator enforcing source-field consistency.

    Mirrors the DB consistency CHECK: an FK-bearing ``source_type`` requires a
    ``source_id``; ``manual`` / ``None`` forbids one. ``source_label`` is
    unconstrained. Attach as a class attribute, e.g. ``_check =
    validate_source_consistency()``.

    Returns
    -------
    classmethod
        A model-level ``after`` validator.
    """

    @model_validator(mode="after")
    def _check(self):
        stype = getattr(self, "source_type", None)
        sid = getattr(self, "source_id", None)
        stype_val = stype.value if isinstance(stype, SourceType) else stype
        if stype_val in FK_SOURCE_TYPES and not sid:
            raise ValueError(
                f"source_id is required when source_type is '{stype_val}'."
            )
        if stype_val in (None, "manual") and sid:
            raise ValueError(
                "source_id must be null when source_type is 'manual' or unset."
            )
        return self

    return _check
```

* [ ] **Step 4: Run test to verify it passes**

Run: `just test-backend tests/test_sources.py -v`
Expected: PASS (all cases).

* [ ] **Step 5: Commit**

```bash theme={null}
git add backend/src/pydantic_models/sources.py backend/tests/test_sources.py
git commit -m "feat(backend): add SourceType enum and source consistency validator"
```

***

## Task 3: Add source fields to Pydantic models

**Files:**

* Modify: `backend/src/pydantic_models/cells.py` (`MaterialPropertyDataset`, `UpdateMaterialPropertyDataset`)

* Modify: `backend/src/pydantic_models/analysis.py` (`Analysis`, `CreateAnalysis`, `UpdateAnalysis`)

* [ ] **Step 1: Write the failing test** (extend `backend/tests/test_sources.py`)

```python theme={null}
def test_analysis_model_accepts_source_fields():
    from datetime import datetime
    from src.pydantic_models.analysis import Analysis

    a = Analysis(
        id="a1", measurement_id="m1", organization_id="o1",
        name="n", analysis_type="ecm_from_eis",
        source_type="pipeline", source_id="p1", source_label="from fit",
        created_at=datetime.now(), updated_at=datetime.now(),
    )
    assert a.source_type == "pipeline" and a.source_id == "p1"


def test_update_material_property_rejects_manual_with_id():
    import pytest
    from pydantic import ValidationError
    from src.pydantic_models.cells import UpdateMaterialPropertyDataset

    with pytest.raises(ValidationError):
        UpdateMaterialPropertyDataset(source_type="manual", source_id="x")
```

* [ ] **Step 2: Run to verify it fails**

Run: `just test-backend tests/test_sources.py -k "source_fields or manual_with_id" -v`
Expected: FAIL — models don't accept `source_type` yet.

* [ ] **Step 3: Implement — `cells.py`**

Add import near the top of `backend/src/pydantic_models/cells.py`:

```python theme={null}
from src.pydantic_models.sources import SourceType, validate_source_consistency
```

Add to `MaterialPropertyDataset` (the read model, after `no_header`):

```python theme={null}
    source_type: SourceType | None = Field(
        None, description="Provenance kind, or null if unknown."
    )
    source_id: str | None = Field(
        None, description="ID of the source row (pipeline/simple_pipeline/analysis)."
    )
    source_label: str | None = Field(
        None, description="Free-text provenance note."
    )

    _validate_source = validate_source_consistency()
```

Add to `UpdateMaterialPropertyDataset` (after `columns`):

```python theme={null}
    source_type: SourceType | None = Field(None, description="New provenance kind.")
    source_id: str | None = Field(None, description="New source row ID.")
    source_label: str | None = Field(None, description="New provenance note.")

    _validate_source = validate_source_consistency()
```

> Note: the read model keeps `_validate_source` too so bad DB rows surface as
> errors; the DB CHECK guarantees they won't exist, so this is belt-and-braces.

* [ ] **Step 4: Implement — `analysis.py`**

Add import:

```python theme={null}
from src.pydantic_models.sources import SourceType, validate_source_consistency
```

Add the three fields to `AnalysisBase` (so both `CreateAnalysis` and `Analysis`
inherit them), after `notes`:

```python theme={null}
    source_type: SourceType | None = Field(
        None, description="Provenance kind, or null if unknown."
    )
    source_id: str | None = Field(
        None, description="ID of the source row (pipeline/simple_pipeline/analysis)."
    )
    source_label: str | None = Field(
        None, description="Free-text provenance note."
    )

    _validate_source = validate_source_consistency()
```

Add the same three fields + `_validate_source` to `UpdateAnalysis` (after `notes`).

* [ ] **Step 5: Run to verify it passes**

Run: `just test-backend tests/test_sources.py -v`
Expected: PASS.

* [ ] **Step 6: Commit**

```bash theme={null}
git add backend/src/pydantic_models/cells.py backend/src/pydantic_models/analysis.py backend/tests/test_sources.py
git commit -m "feat(backend): add source fields to material property and analysis models"
```

***

## Task 4: Service-layer source validation — material property datasets

**Files:**

* Modify: `backend/src/services/material_property_dataset_service.py`
* Test: `backend/tests/test_services/test_material_property_dataset_service.py`

**Design:** add a private `_validate_source(self, source_type, source_id)` that,
for FK-bearing types, fetches the source via the matching repo built from the
**same user client** the service's `prop_repo` already holds
(`self.prop_repo.supabase`), and raises `NotFoundError(source_type, source_id)`
when the fetch returns `None`. `manual`/`None` → no-op. Call it from `create`
and `update` before the DB write. Thread `source_type`/`source_id`/`source_label`
into the create/update payloads.

* [ ] **Step 1: Write the failing test**

```python theme={null}
# backend/tests/test_services/test_material_property_dataset_service.py
import pytest
from unittest.mock import AsyncMock, MagicMock

from src.exceptions import NotFoundError
from src.pydantic_models.sources import SourceType
from src.services.material_property_dataset_service import (
    MaterialPropertyDatasetService,
)


def _service(source_exists: bool):
    prop_repo = MagicMock()
    prop_repo.supabase = MagicMock()
    bucket_repo = MagicMock()
    svc = MaterialPropertyDatasetService(prop_repo=prop_repo, bucket_repo=bucket_repo)
    # Patch the source-repo lookups the validator dispatches to.
    fake = MagicMock() if source_exists else None
    svc._fetch_source = AsyncMock(return_value=fake)  # helper indirection
    return svc


@pytest.mark.asyncio
async def test_validate_source_manual_noop():
    svc = _service(source_exists=False)
    await svc._validate_source(SourceType.MANUAL, None)  # no raise


@pytest.mark.asyncio
async def test_validate_source_missing_raises():
    svc = _service(source_exists=False)
    with pytest.raises(NotFoundError):
        await svc._validate_source(SourceType.PIPELINE, "missing-id")


@pytest.mark.asyncio
async def test_validate_source_present_ok():
    svc = _service(source_exists=True)
    await svc._validate_source(SourceType.ANALYSIS, "an-id")  # no raise
```

> If a service test file already exists, append these; keep the existing
> fixtures. The `_fetch_source` indirection keeps the test from needing real
> repos — implement `_validate_source` to call `self._fetch_source(type, id)`.

* [ ] **Step 2: Run to verify it fails**

Run: `just test-backend tests/test_services/test_material_property_dataset_service.py -k validate_source -v`
Expected: FAIL — `_validate_source` / `_fetch_source` not defined.

* [ ] **Step 3: Implement the helpers**

Add imports to `material_property_dataset_service.py`:

```python theme={null}
from src.pydantic_models.sources import SourceType
from src.repositories.analysis import AnalysisRepository
from src.repositories.pipelines import PipelineRepository
from src.repositories.simple_pipelines import SimplePipelineRepository
```

Add methods to `MaterialPropertyDatasetService`:

```python theme={null}
    async def _fetch_source(self, source_type: SourceType, source_id: str):
        """Fetch a source row via a repo built from the RLS user client.

        Uses ``self.prop_repo.supabase`` (a user-scoped client) so RLS decides
        visibility — a row the caller cannot see returns ``None`` and is treated
        as absent. This is the only integrity guarantee for the polymorphic
        ``source_id`` (there is no DB foreign key).
        """
        client = self.prop_repo.supabase
        if source_type == SourceType.PIPELINE:
            return await PipelineRepository(client).get_by_id(source_id)
        if source_type == SourceType.SIMPLE_PIPELINE:
            return await SimplePipelineRepository(client).get_by_id(source_id)
        if source_type == SourceType.ANALYSIS:
            return await AnalysisRepository(client).get_analysis(source_id)
        return None

    async def _validate_source(
        self, source_type: SourceType | None, source_id: str | None
    ) -> None:
        """Raise ``NotFoundError`` if an FK-bearing source does not resolve.

        ``manual`` / ``None`` require no lookup.
        """
        if source_type is None or source_type == SourceType.MANUAL:
            return
        found = await self._fetch_source(source_type, source_id)
        if found is None:
            raise NotFoundError(source_type.value, source_id)
```

* [ ] **Step 4: Run to verify it passes**

Run: `just test-backend tests/test_services/test_material_property_dataset_service.py -k validate_source -v`
Expected: PASS.

* [ ] **Step 5: Wire source into `create` and `update`**

In `create(...)`: add `source_type`, `source_id`, `source_label` keyword params
(all defaulting to `None`), call `await self._validate_source(source_type, source_id)`
right after the empty-columns guard (before the parquet computation is fine too,
but do it before the DB write), and add the three fields to the `data={...}`
dict passed to `create_property` (only when not None, or always — nulls are
fine). In `update(...)`: add the same three params; if any is provided, call
`_validate_source` (using the effective `source_type`), and add provided fields
to `new_data`. Remember `source_label` alone (no type) is valid.

* [ ] **Step 6: Update the route to pass source on create**

In `backend/src/routes/material_property_datasets.py` `create_material_property_dataset`:
add `Form(None)` params `source_type: SourceType | None`, `source_id: str | None`,
`source_label: str | None`, and pass them to `service.create(...)`. In
`update_material_property_dataset`, pass `source_type=body.source_type`,
`source_id=body.source_id`, `source_label=body.source_label` to `service.update`.
Import `SourceType` from `src.pydantic_models.sources`.

* [ ] **Step 7: Run the full service + route test module**

Run: `just test-backend tests/test_services/test_material_property_dataset_service.py -v`
Expected: PASS (new + any existing).

* [ ] **Step 8: Commit**

```bash theme={null}
git add backend/src/services/material_property_dataset_service.py backend/src/routes/material_property_datasets.py backend/tests/test_services/test_material_property_dataset_service.py
git commit -m "feat(backend): validate and persist source on material property datasets"
```

***

## Task 5: Service-layer source validation — analyses

**Files:**

* Modify: `backend/src/services/analysis_service.py`
* Modify: `backend/src/routes/analysis.py`
* Test: `backend/tests/test_services/test_analysis_service.py`

Mirror Task 4. The analysis service holds `self.analysis_repo` — build source
repos from `self.analysis_repo.supabase`.

> **Note:** the create wire path is the route's `Form(...)` fields →
> `service.create(...)` kwargs, **not** `CreateAnalysis`. Task 3 adds the source
> fields to `AnalysisBase` (which `CreateAnalysis` inherits) only so the read
> model and any future `CreateAnalysis` use stay consistent — do not route
> create through `CreateAnalysis`.

* [ ] **Step 1: Write the failing test**

```python theme={null}
# backend/tests/test_services/test_analysis_service.py (append if it exists)
import pytest
from unittest.mock import AsyncMock, MagicMock

from src.exceptions import NotFoundError
from src.pydantic_models.sources import SourceType
from src.services.analysis_service import AnalysisService


def _service(source_exists: bool):
    analysis_repo = MagicMock()
    analysis_repo.supabase = MagicMock()
    svc = AnalysisService(
        analysis_repo=analysis_repo,
        bucket_repo=MagicMock(),
        measurement_repo=MagicMock(),
    )
    svc._fetch_source = AsyncMock(return_value=(MagicMock() if source_exists else None))
    return svc


@pytest.mark.asyncio
async def test_analysis_validate_source_missing_raises():
    svc = _service(source_exists=False)
    with pytest.raises(NotFoundError):
        await svc._validate_source(SourceType.SIMPLE_PIPELINE, "missing")


@pytest.mark.asyncio
async def test_analysis_validate_source_manual_noop():
    svc = _service(source_exists=False)
    await svc._validate_source(SourceType.MANUAL, None)
```

* [ ] **Step 2: Run to verify it fails**

Run: `just test-backend tests/test_services/test_analysis_service.py -k validate_source -v`
Expected: FAIL.

* [ ] **Step 3: Implement `_fetch_source` + `_validate_source`** on `AnalysisService`
  (identical to Task 4 but `client = self.analysis_repo.supabase`). Add the same
  three repo imports + `SourceType`.

* [ ] **Step 4: Run to verify it passes**

Run: `just test-backend tests/test_services/test_analysis_service.py -k validate_source -v`
Expected: PASS.

* [ ] **Step 5: Wire source into `create` and `update`** — add the three params,
  validate before the DB write, thread into the `data={...}` (create) and
  `new_data` (update) payloads.

* [ ] **Step 6: Update the route** — `create_analysis` gets `Form(None)` source
  params passed to `service.create`; `update_analysis` passes
  `body.source_type/source_id/source_label` to `service.update`. Import `SourceType`.

* [ ] **Step 7: Run the module**

Run: `just test-backend tests/test_services/test_analysis_service.py -v`
Expected: PASS.

* [ ] **Step 8: Commit**

```bash theme={null}
git add backend/src/services/analysis_service.py backend/src/routes/analysis.py backend/tests/test_services/test_analysis_service.py
git commit -m "feat(backend): validate and persist source on analyses"
```

***

## Task 6: Public SDK parity (`ionworks-api`)

**Files:**

* Modify: `packages/ionworks-api/ionworks/models.py`

The `Analysis` and `MaterialPropertyDataset` SDK models use
`ConfigDict(extra="allow")`, so source fields already round-trip. Add them
explicitly + a `SourceType` enum for discoverability and parity (per the
schema/pipeline parity rule). **No confidential content.**

* [ ] **Step 1: Add `SourceType` enum** near `AnalysisType` in `models.py`:

```python theme={null}
class SourceType(StrEnum):
    """Provenance kind for a material property dataset or analysis.

    Records where the data came from. This is a closed set on the platform;
    ``manual`` means a hand-uploaded dataset with no computed source.
    """

    PIPELINE = "pipeline"
    SIMPLE_PIPELINE = "simple_pipeline"
    ANALYSIS = "analysis"
    MANUAL = "manual"
```

* [ ] **Step 2: Add fields** to `Analysis` and `MaterialPropertyDataset`:

```python theme={null}
    source_type: str | None = None
    source_id: str | None = None
    source_label: str | None = None
```

(Use `str | None` to stay lenient with the SDK's `extra="allow"` posture; the
enum is exported for callers who want it.)

* [ ] **Step 3: Verify import + smoke**

Run: `just test-api -k "analysis or material or source" -v` (or the package's default test recipe if narrower selection finds nothing).
Expected: PASS / no import error. If no relevant tests exist, run `uv run python -c "from ionworks.models import SourceType, Analysis; print(SourceType.PIPELINE)"` from `packages/ionworks-api`.

* [ ] **Step 4: Commit**

```bash theme={null}
git add packages/ionworks-api/ionworks/models.py
git commit -m "feat(api): expose source fields and SourceType on SDK models"
```

***

## Task 7: Frontend types + source-link resolver

**Files:**

* Modify: `frontend/src/redux/types/material-property-dataset.ts`

* Modify: `frontend/src/redux/api/analyses-api.ts`

* Create: `frontend/src/utils/source-link.ts`

* Test: `frontend/src/utils/source-link.test.ts`

* [ ] **Step 1: Add fields to the TS interfaces**

In `material-property-dataset.ts` `MaterialPropertyDataset`, after `no_header`:

```typescript theme={null}
  source_type: 'pipeline' | 'simple_pipeline' | 'analysis' | 'manual' | null;
  source_id: string | null;
  source_label: string | null;
```

In `analyses-api.ts` `Analysis`, after `notes`: the same three fields.

* [ ] **Step 2: Write the failing test for the resolver**

```typescript theme={null}
// frontend/src/utils/source-link.test.ts
import { describe, it, expect } from 'vitest';
import { resolveSourceHref, sourceTypeLabel } from './source-link';
import { paths } from 'src/routes/paths';

describe('resolveSourceHref', () => {
  it('links a pipeline source to the pipeline detail page', () => {
    expect(resolveSourceHref('pipeline', 'p1', 'proj1')).toBe(
      paths.dashboard.pipelines.detail('proj1', 'p1')
    );
  });
  it('links an analysis source to the analysis detail page', () => {
    expect(resolveSourceHref('analysis', 'a1', 'proj1')).toBe(
      paths.dashboard.dataManagement.analysisDetail('proj1', 'a1')
    );
  });
  it('returns null for simple_pipeline (no detail route)', () => {
    expect(resolveSourceHref('simple_pipeline', 's1', 'proj1')).toBeNull();
  });
  it('returns null for manual / null / missing id / missing project', () => {
    expect(resolveSourceHref('manual', null, 'proj1')).toBeNull();
    expect(resolveSourceHref(null, null, 'proj1')).toBeNull();
    expect(resolveSourceHref('pipeline', 'p1', null)).toBeNull();
    expect(resolveSourceHref('pipeline', null, 'proj1')).toBeNull();
  });
});

describe('sourceTypeLabel', () => {
  it('humanizes each type', () => {
    expect(sourceTypeLabel('simple_pipeline')).toBe('Simple pipeline');
    expect(sourceTypeLabel('manual')).toBe('Manual');
    expect(sourceTypeLabel(null)).toBe('—');
  });
});
```

* [ ] **Step 3: Run to verify it fails**

Run: `just test-frontend-unit src/utils/source-link.test.ts`
Expected: FAIL — module not found.

* [ ] **Step 4: Implement the resolver**

```typescript theme={null}
// frontend/src/utils/source-link.ts
import { paths } from 'src/routes/paths';

export type SourceType = 'pipeline' | 'simple_pipeline' | 'analysis' | 'manual';

/**
 * Resolve a provenance source to a detail-page href, or null when it cannot be
 * linked (manual/unknown source, missing id/project, or a type with no detail
 * route such as simple_pipeline). Callers render a plain label when null.
 */
export function resolveSourceHref(
  sourceType: SourceType | null,
  sourceId: string | null,
  projectId: string | null
): string | null {
  if (!sourceType || !sourceId || !projectId) return null;
  switch (sourceType) {
    case 'pipeline':
      return paths.dashboard.pipelines.detail(projectId, sourceId);
    case 'analysis':
      return paths.dashboard.dataManagement.analysisDetail(projectId, sourceId);
    default:
      return null; // simple_pipeline has no dedicated detail route; manual unreachable
  }
}

const LABELS: Record<SourceType, string> = {
  pipeline: 'Pipeline',
  simple_pipeline: 'Simple pipeline',
  analysis: 'Analysis',
  manual: 'Manual',
};

/** Human-readable label for a source type ('—' when null/unknown). */
export function sourceTypeLabel(sourceType: SourceType | null): string {
  return sourceType ? LABELS[sourceType] : '—';
}
```

> Verify `paths.dashboard.dataManagement.analysisDetail` is the correct accessor
> (it's declared at `frontend/src/routes/paths.ts:291`). Adjust the path
> reference if the namespace differs.

* [ ] **Step 5: Run to verify it passes**

Run: `just test-frontend-unit src/utils/source-link.test.ts`
Expected: PASS.

* [ ] **Step 6: Commit**

```bash theme={null}
git add frontend/src/redux/types/material-property-dataset.ts frontend/src/redux/api/analyses-api.ts frontend/src/utils/source-link.ts frontend/src/utils/source-link.test.ts
git commit -m "feat(frontend): add source fields to types and a source-link resolver"
```

***

## Task 8: Dataset update thunk carries source fields

**Files:**

* Modify: `frontend/src/redux/slices/model/material-property-datasets-slice.ts`

* Test: `frontend/src/redux/slices/model/material-property-datasets-slice.test.ts` (create if absent)

* [ ] **Step 1: Write the failing test**

```typescript theme={null}
// material-property-datasets-slice.test.ts
import { describe, it, expect } from 'vitest';
import { mockAxios, type HandlerContext } from 'src/test-utils/mock-axios';
import { makeTestStore } from 'src/test-utils/make-test-store';
import { updateMaterialPropertyDataset } from './material-property-datasets-slice';

describe('updateMaterialPropertyDataset', () => {
  it('sends source fields in the PATCH body', async () => {
    const store = makeTestStore();
    let captured: unknown;
    // onPatch(match, handler): the handler receives { url, config, body } and
    // RETURNS the raw response.data. Capture the request body in a closure.
    mockAxios.onPatch(/\/material_property_datasets\/.+/, ({ body }: HandlerContext) => {
      captured = body;
      return { id: 'd1', source_type: 'pipeline', source_id: 'p1' };
    });

    await store.dispatch(
      updateMaterialPropertyDataset({
        propertyId: 'd1',
        materialId: 'm1',
        source_type: 'pipeline',
        source_id: 'p1',
        source_label: 'from fit',
      })
    );

    // body is the OBJECT passed to axios.patch(url, body) — not a JSON string.
    expect(captured).toMatchObject({
      source_type: 'pipeline',
      source_id: 'p1',
      source_label: 'from fit',
    });
  });
});
```

> Reference the real mock API in
> `frontend/src/redux/slices/model/cell-instances-slice.test.ts` (imports
> `mockAxios`, `HandlerContext` from `src/test-utils/mock-axios` and
> `makeTestStore` from `src/test-utils/make-test-store`). There is **no**
> `src/test-utils` barrel, no `.reply()`, and no `.history` — read the request
> body from the handler's `{ body }` arg (an object), or from
> `mockAxios.patch.mock.calls`.

* [ ] **Step 2: Run to verify it fails**

Run: `just test-frontend-unit src/redux/slices/model/material-property-datasets-slice.test.ts`
Expected: FAIL — thunk doesn't accept/send source fields.

* [ ] **Step 3: Implement** — widen the `updateMaterialPropertyDataset` arg type
  and PATCH body to include `source_type?`, `source_id?`, `source_label?`; widen
  `updateProperty(propertyId, patch)`'s `patch` type in `useReduxMaterialProperties`
  to include the three optional source fields.

* [ ] **Step 4: Run to verify it passes**

Run: `just test-frontend-unit src/redux/slices/model/material-property-datasets-slice.test.ts`
Expected: PASS.

* [ ] **Step 5: Commit**

```bash theme={null}
git add frontend/src/redux/slices/model/material-property-datasets-slice.ts frontend/src/redux/slices/model/material-property-datasets-slice.test.ts
git commit -m "feat(frontend): thread source fields through the dataset update thunk"
```

***

## Task 9: Dataset edit dialog — source editing

**Files:**

* Modify: `frontend/src/sections/materials/material-property-dataset-edit-dialog.tsx`

* [ ] **Step 1: Extend the Zod schema + form** — add to `EditSchema`:

```typescript theme={null}
  source_type: zod.enum(['pipeline', 'simple_pipeline', 'analysis', 'manual']).nullable(),
  source_id: zod.string().nullable(),
  source_label: zod.string().nullable(),
```

Seed defaults in the `reset({...})` inside the `open` effect from `property`.

* [ ] **Step 2: Add UI** — a "Source" `Paper` section with:
  * `Field.Select` for `source_type` (options: Pipeline / Simple pipeline /
    Analysis / Manual, plus an empty "Unknown" choice).
  * A `source_id` `TextField` — disabled/cleared when type is `manual` or empty
    (mirror the DB/consistency rule: clear `source_id` when switching to
    `manual`/none).
  * A `source_label` `TextField` (free text).

Use theme tokens; keep the existing dialog styling idiom.

* [ ] **Step 3: Include source in the patch diff** — in `handleFormSubmit`,
  after the existing name/columns diff, add each source field to `patch` when it
  differs from `property`. Widen the local `patch` type to include the three
  fields. (These go through the non-file `onSave` path; the file-replacement path
  need not carry source.)

* [ ] **Step 4: Manual verification in the browser** (no unit test for the dialog UI)

Start servers and log in (per this repo's session setup), open a material with a
property dataset, edit it, set Source = Pipeline with a real pipeline id + a
label, save. Confirm the PATCH succeeds and the grid reflects it (Task 10).
Then set Source = Manual and confirm `source_id` is cleared and the save
succeeds. Verify light + dark mode render.

* [ ] **Step 5: Commit**

```bash theme={null}
git add frontend/src/sections/materials/material-property-dataset-edit-dialog.tsx
git commit -m "feat(frontend): edit material property dataset source in the edit dialog"
```

***

## Task 10: Dataset grid — "Source" column

**Files:**

* Modify: `frontend/src/sections/materials/material-detail-container.tsx`

* [ ] **Step 1: Add a `source` column** to the `columns: GridColDef[]` array
  (between `columns` and `created_at`):

```typescript theme={null}
    {
      field: 'source',
      headerName: 'Source',
      flex: 1,
      sortable: false,
      renderCell: (params) => {
        const row = params.row;
        if (!row.source_type) return <Typography variant="body2" color="text.secondary">—</Typography>;
        const href = resolveSourceHref(row.source_type, row.source_id, row.project_id);
        const label = row.source_label || sourceTypeLabel(row.source_type);
        return href ? (
          <Typography
            component={RouterLink}
            href={href}
            color="primary"
            variant="body2"
            lineHeight="inherit"
          >
            {label}
          </Typography>
        ) : (
          <Chip label={label} size="small" variant="soft" color="default" />
        );
      },
    },
```

* [ ] **Step 2: Add imports** — `resolveSourceHref, sourceTypeLabel` from
  `src/utils/source-link`; `RouterLink` from `src/routes/components`.

* [ ] **Step 3: Verify build + lint**

Run: `just test-frontend-unit` (whole suite, quick) and ensure typecheck passes
via the pre-commit hook on commit. Optionally verify in the browser that the
Source column renders a link for a pipeline source and a chip for a
simple\_pipeline/manual source.

* [ ] **Step 4: Commit**

```bash theme={null}
git add frontend/src/sections/materials/material-detail-container.tsx
git commit -m "feat(frontend): show source column on the material datasets grid"
```

***

## Task 11: Analysis detail — read-only Source row

**Files:**

* Modify: `frontend/src/sections/cell/analysis-details-container.tsx`

* [ ] **Step 1: Add a Source display** in the left metadata column, near the
  existing "View source measurement" button. Render only when
  `analysis.source_type` is set:

```typescript theme={null}
{analysis.source_type && (() => {
  const href = resolveSourceHref(analysis.source_type, analysis.source_id, projectId ?? null);
  const label = analysis.source_label || sourceTypeLabel(analysis.source_type);
  return (
    <Stack direction="row" spacing={1} alignItems="center">
      <Chip label={sourceTypeLabel(analysis.source_type)} size="small" variant="soft" />
      {href ? (
        <Typography component={RouterLink} href={href} color="primary" variant="body2">
          {label}
        </Typography>
      ) : (
        <Typography variant="body2" color="text.secondary">{label}</Typography>
      )}
    </Stack>
  );
})()}
```

* [ ] **Step 2: Add imports** — `resolveSourceHref, sourceTypeLabel` from
  `src/utils/source-link` (`RouterLink`, `paths`, `Chip`, `Stack`, `Typography`
  are already imported — confirm and add any missing). Add `source_type`,
  `source_id`, `source_label` to the `ObjectFieldsDisplay` `excludeFields` list so
  they aren't double-rendered.

* [ ] **Step 3: Verify**

Run: `just test-frontend-unit` and typecheck via commit hook. Optionally browser-
verify on an analysis that has a source set (set one via the API/DB first).

* [ ] **Step 4: Commit**

```bash theme={null}
git add frontend/src/sections/cell/analysis-details-container.tsx
git commit -m "feat(frontend): show read-only source on the analysis detail page"
```

***

## Task 12: Full-suite verification + PR

* [ ] **Step 1: Backend tests**

Run: `just test-backend tests/test_sources.py tests/test_services/test_material_property_dataset_service.py tests/test_services/test_analysis_service.py -v`
Expected: PASS.

* [ ] **Step 2: Frontend unit tests**

Run: `just test-frontend-unit`
Expected: PASS.

* [ ] **Step 3: End-to-end smoke (browser)** — per the verification workflow:
  start frontend + backend, log in, and exercise: create/edit a dataset source
  (link resolves for pipeline, chip for simple\_pipeline/manual), and view an
  analysis with a source set. Capture a screenshot for the PR.

* [ ] **Step 4: Open the PR**

Push `feat/material-property-source-tracking` and open a PR with title
`feat: source tracking for material property datasets and analyses`, body
summarizing the polymorphic model, RLS-scoped validation, and the UI surfaces,
linking the spec. Use the `/git-workflow` and `github-pr-labels` skills.
Note the pre-existing duplicate-migration hook issue on main if it surfaces in CI.

***

## Notes / risks

* **No auto-population.** Pipelines that produce datasets do not yet stamp
  source automatically — deliberate follow-up. This PR adds the columns + manual/
  API/UI paths only.
* **No reverse index.** `(source_type, source_id)` is unindexed by design (no
  reverse-lookup feature). Add a partial index later if one appears.
* **RLS is the integrity boundary.** `_fetch_source` must use the user client
  (`self.<repo>.supabase`), never a service client, or the "visible to caller"
  guarantee is lost. This is the one easy-to-get-wrong detail.
* **`simple_pipeline` has no detail route** — the UI renders it as a chip, not a
  link. If a route is added later, extend `resolveSourceHref`.
