Skip to main content

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.pySourceType 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.pySourceType + 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
  • 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

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
  • 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
  • Step 4: Run test to verify it passes
Run: just test-backend tests/test_sources.py -v Expected: PASS (all cases).
  • Step 5: Commit

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)
  • 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:
Add to MaterialPropertyDataset (the read model, after no_header):
Add to UpdateMaterialPropertyDataset (after columns):
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:
Add the three fields to AnalysisBase (so both CreateAnalysis and Analysis inherit them), after notes:
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

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
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:
Add methods to MaterialPropertyDatasetService:
  • 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

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
  • 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 routecreate_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

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:
  • Step 2: Add fields to Analysis and MaterialPropertyDataset:
(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

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:
In analyses-api.ts Analysis, after notes: the same three fields.
  • Step 2: Write the failing test for the resolver
  • 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
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

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
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

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:
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

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):
  • Step 2: Add importsresolveSourceHref, 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

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:
  • Step 2: Add importsresolveSourceHref, 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

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.