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).
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 viaDO $$ … EXCEPTION WHEN duplicate_object THEN NULL; END $$;). See precedentsupabase/migrations/20260503200000_add_model_chemistry.sql:14-19. - Services raise domain exceptions (
NotFoundError,BadRequestError), neverHTTPExceptionor bareValueError. - Frontend update calls use
axios.patch; times absolute; dark-mode-safe theme tokens;extractApiErrorfor errors. iwutil/ionworksdata/ionworks-schema/ionworks-apiare public-mirrored — no confidential content.ionworks-apimust stay in parity with API models.- Run tests via
just, Python viauv. Never bump package versions manually.
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—SourceTypeStrEnum + sharedSourceFieldsmixin + validator helper. - Modify:
backend/src/pydantic_models/cells.py— add source fields toMaterialPropertyDataset(read) +UpdateMaterialPropertyDataset. - Modify:
backend/src/pydantic_models/analysis.py— add source fields toAnalysis(read),CreateAnalysis,UpdateAnalysis. - Modify:
backend/src/services/material_property_dataset_service.py— accept + validate source oncreate/update; new_validate_sourcehelper; inject source repos. - Modify:
backend/src/services/analysis_service.py— same. - Modify:
backend/src/routes/material_property_datasets.py— accept sourceForm(...)fields on create; update handler already threads the body model. - Modify:
backend/src/routes/analysis.py— same. - Modify:
packages/ionworks-api/ionworks/models.py— addSourceTypeenum + fields toAnalysisandMaterialPropertyDataset.
- 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.
- Modify:
frontend/src/redux/types/material-property-dataset.ts— add source fields. - Modify:
frontend/src/redux/api/analyses-api.ts— add source fields toAnalysis. - Create:
frontend/src/utils/source-link.ts—(source_type, source_id, project_id) → href | nullresolver + 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.
- 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
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
DO $$ … $$ blocks in psql).
Expected: no error (columns IF NOT EXISTS, constraints swallow duplicate_object).
- Step 4: Verify the CHECK rejects bad rows
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
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
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
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
backend/src/pydantic_models/cells.py:
MaterialPropertyDataset (the read model, after no_header):
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
AnalysisBase (so both CreateAnalysis and Analysis
inherit them), after notes:
_validate_source to UpdateAnalysis (after notes).
- Step 5: Run to verify it passes
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
_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_sourceindirection keeps the test from needing real repos — implement_validate_sourceto callself._fetch_source(type, id).
- Step 2: Run to verify it fails
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
material_property_dataset_service.py:
MaterialPropertyDatasetService:
- Step 4: Run to verify it passes
just test-backend tests/test_services/test_material_property_dataset_service.py -k validate_source -v
Expected: PASS.
- Step 5: Wire source into
createandupdate
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
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
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
self.analysis_repo — build source
repos from self.analysis_repo.supabase.
Note: the create wire path is the route’sForm(...)fields →service.create(...)kwargs, notCreateAnalysis. Task 3 adds the source fields toAnalysisBase(whichCreateAnalysisinherits) only so the read model and any futureCreateAnalysisuse stay consistent — do not route create throughCreateAnalysis.
- Step 1: Write the failing test
- Step 2: Run to verify it fails
just test-backend tests/test_services/test_analysis_service.py -k validate_source -v
Expected: FAIL.
-
Step 3: Implement
_fetch_source+_validate_sourceonAnalysisService(identical to Task 4 butclient = self.analysis_repo.supabase). Add the same three repo imports +SourceType. - Step 4: Run to verify it passes
just test-backend tests/test_services/test_analysis_service.py -k validate_source -v
Expected: PASS.
-
Step 5: Wire source into
createandupdate— add the three params, validate before the DB write, thread into thedata={...}(create) andnew_data(update) payloads. -
Step 6: Update the route —
create_analysisgetsForm(None)source params passed toservice.create;update_analysispassesbody.source_type/source_id/source_labeltoservice.update. ImportSourceType. - Step 7: Run the module
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
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
SourceTypeenum nearAnalysisTypeinmodels.py:
- Step 2: Add fields to
AnalysisandMaterialPropertyDataset:
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
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
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
material-property-dataset.ts MaterialPropertyDataset, after no_header:
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
just test-frontend-unit src/utils/source-link.test.ts
Expected: FAIL — module not found.
- Step 4: Implement the resolver
Verifypaths.dashboard.dataManagement.analysisDetailis the correct accessor (it’s declared atfrontend/src/routes/paths.ts:291). Adjust the path reference if the namespace differs.
- Step 5: Run to verify it passes
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 infrontend/src/redux/slices/model/cell-instances-slice.test.ts(importsmockAxios,HandlerContextfromsrc/test-utils/mock-axiosandmakeTestStorefromsrc/test-utils/make-test-store). There is nosrc/test-utilsbarrel, no.reply(), and no.history— read the request body from the handler’s{ body }arg (an object), or frommockAxios.patch.mock.calls.
- Step 2: Run to verify it fails
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
updateMaterialPropertyDatasetarg type and PATCH body to includesource_type?,source_id?,source_label?; widenupdateProperty(propertyId, patch)’spatchtype inuseReduxMaterialPropertiesto include the three optional source fields. - Step 4: Run to verify it passes
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:
reset({...}) inside the open effect from property.
- Step 2: Add UI — a “Source”
Papersection with:Field.Selectforsource_type(options: Pipeline / Simple pipeline / Analysis / Manual, plus an empty “Unknown” choice).- A
source_idTextField— disabled/cleared when type ismanualor empty (mirror the DB/consistency rule: clearsource_idwhen switching tomanual/none). - A
source_labelTextField(free text).
-
Step 3: Include source in the patch diff — in
handleFormSubmit, after the existing name/columns diff, add each source field topatchwhen it differs fromproperty. Widen the localpatchtype to include the three fields. (These go through the non-fileonSavepath; the file-replacement path need not carry source.) - Step 4: Manual verification in the browser (no unit test for the dialog UI)
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
sourcecolumn to thecolumns: GridColDef[]array (betweencolumnsandcreated_at):
-
Step 2: Add imports —
resolveSourceHref, sourceTypeLabelfromsrc/utils/source-link;RouterLinkfromsrc/routes/components. - Step 3: Verify build + lint
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_typeis set:
-
Step 2: Add imports —
resolveSourceHref, sourceTypeLabelfromsrc/utils/source-link(RouterLink,paths,Chip,Stack,Typographyare already imported — confirm and add any missing). Addsource_type,source_id,source_labelto theObjectFieldsDisplayexcludeFieldslist so they aren’t double-rendered. - Step 3: Verify
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
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
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
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_sourcemust 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_pipelinehas no detail route — the UI renders it as a chip, not a link. If a route is added later, extendresolveSourceHref.