Raw Data Storage 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: Add an org + project-scoped raw-data subsystem that stores uploaded files as-is in a dedicated raw-data Supabase bucket and links them many-to-many to cell measurements, exposed via the FastAPI backend and the ionworks-api Python SDK.
Architecture: Mirror the analyses trio (repo + storage-repo + service + routes) and the material_property_datasets org+project precedent. New raw_data table (required organization_id + project_id, composite FK to projects), new measurement_raw_data_sources join table (many-to-many, cascade both sides), new raw-data bucket with org-path-token storage RLS. Provenance attach/detach is measurement-centric and lives in cell_measurements routes/SDK. No web UI this phase.
Tech Stack: FastAPI, Supabase (Postgres + Storage), Pydantic v2, pytest (just test-backend), requests-based Python SDK (ionworks-api), mocked-HTTP SDK tests.
Spec: docs/superpowers/specs/2026-07-13-raw-data-storage-design.md
Key precedents to copy (read these first):
- Migration:
supabase/migrations/20260711000000_add_analyses.sql(table + RLS + trigger idempotency),supabase/migrations/20260504100000_add_material_property_datasets.sql(composite(project_id, organization_id)FK + storage RLS),supabase/migrations/20260504100001_create_material_property_datasets_bucket.sql(bucket INSERT). - Storage RLS verb set:
measurement-datapolicies usestorage:create/read/update/delete(baseline.sql:7028-7095). Table RLS uses domain verbs (cell_measurement:*). - Backend:
backend/src/repositories/analysis.py,analysis_bucket.py,backend/src/services/analysis_service.py,backend/src/routes/analysis.py,backend/src/pydantic_models/analysis.py. - Bucket repo with raw+org path:
backend/src/repositories/material_property_datasets_bucket.py. - SDK:
packages/ionworks-api/ionworks/custom_model.py:229(upload_custom→upload_multipart),client.py:197-218(sub-client wiring),client.py:405(upload_multipart),models.py(pydanticextra="allow"),cell_measurement.py(measurement client). - Tests:
backend/tests/test_repositories/test_analysis_repository.py,test_services/test_analysis_service.py,test_routes/test_analysis_routes.py; SDKpackages/ionworks-api/tests/test_measurement_types.py.
- Repositories paginate with
.select("*", count="exact")+.range(offset, offset+limit-1); returnPaginatedList. - Services raise domain exceptions (
NotFoundError,BadRequestError,ConflictError,AppError,ExternalServiceError), neverHTTPExceptionor bareValueError. Updates usePATCH.organization_idnever in URL path — comes fromDepends(get_current_organization_id). - Migrations: DDL-only + the one sanctioned bucket
INSERT ... ON CONFLICT DO NOTHING; additive, idempotent. - numpy-style docstrings, ≤88 col.
File Structure
Create:supabase/migrations/<ts>_add_raw_data.sql—raw_datatable,measurement_raw_data_sourcesjoin table, indexes, trigger, table + storage RLS.supabase/migrations/<ts>_create_raw_data_bucket.sql— theraw-databucket INSERT.supabase/seeds/buckets.sql— modify: addraw-datato the seed list.backend/src/pydantic_models/raw_data.py—RawData,UpdateRawData,AttachRawData,RawDataListResponsemodels.backend/src/repositories/raw_data.py—RawDataRepository+ join-table methods + dep.backend/src/repositories/raw_data_bucket.py—RawDataBucketRepository+ dep.backend/src/services/raw_data_service.py—RawDataService+ dep.backend/src/routes/raw_data.py—/raw_dataCRUD router.backend/tests/test_repositories/test_raw_data_repository.pybackend/tests/test_services/test_raw_data_service.pybackend/tests/test_routes/test_raw_data_routes.pypackages/ionworks-api/ionworks/raw_data.py—RawDataClient.packages/ionworks-api/tests/test_raw_data.py
backend/src/main.py— mountraw_data_routerat/raw_data.backend/src/routes/cell_measurements.py— add attach/detach/list-raw-data sub-routes ondirect_router.backend/src/services/cell_measurements.py(or the service backing those routes) — attach/detach/list orchestration.packages/ionworks-api/ionworks/client.py— wireself.raw_data = RawDataClient(self).packages/ionworks-api/ionworks/cell_measurement.py— addattach_raw_data/detach_raw_data/list_raw_data.packages/ionworks-api/ionworks/models.py— addRawDatamodel.packages/ionworks-api/ionworks/__init__.py— exportRawData.- SDK skills (
discover-api,upload-data) viaionworks-dev-sync— final task.
Task 1: Database migration — table, join table, bucket, RLS
Files:- Create:
supabase/migrations/<timestamp>_add_raw_data.sql - Create:
supabase/migrations/<timestamp>_create_raw_data_bucket.sql - Modify:
supabase/seeds/buckets.sql
ls supabase/migrations | sort | tail -1 then pick a larger YYYYMMDDHHMMSS.
- Step 1: Write
<ts>_add_raw_data.sql
DO $$ ... pg_constraint guards) verbatim from 20260711000000_add_analyses.sql and the composite-FK block from 20260504100000_add_material_property_datasets.sql. Contents:
- Step 2: Write
<ts>_create_raw_data_bucket.sql
- Step 3: Add storage RLS for the
raw-databucket
storage.objects policies to <ts>_add_raw_data.sql, copying the measurement-data block from baseline.sql:7061-7095 but with bucket_id = 'raw-data' and the storage:* verb set (this is the verb set every bucket uses; do NOT use cell_measurement:* here):
- Step 4: Add
raw-datato the seed bucket list
supabase/seeds/buckets.sql, add a ('raw-data', 'raw-data', false) row to the existing INSERT ... ON CONFLICT DO NOTHING list (match the column shape already there).
- Step 5: Apply migrations and verify
supabase migration up (or the repo’s migration command; do NOT supabase db reset — see memory note).
Expected: both migrations apply cleanly; re-running is a no-op (idempotent).
Verify tables exist:
psql "$SUPABASE_DB_URL" -c "\d public.raw_data" -c "\d public.measurement_raw_data_sources"
Verify bucket: psql ... -c "select id from storage.buckets where id='raw-data';" → 1 row.
- Step 6: Commit
Task 2: Pydantic models
Files:- Create:
backend/src/pydantic_models/raw_data.py
backend/src/pydantic_models/analysis.py.
- Step 1: Write the models
- Step 2: Import-check
cd backend && uv run python -c "from src.pydantic_models.raw_data import RawData, UpdateRawData, AttachRawData"
Expected: no error.
- Step 3: Commit
Task 3: DB repository (TDD)
Files:- Create:
backend/src/repositories/raw_data.py - Test:
backend/tests/test_repositories/test_raw_data_repository.py
backend/src/repositories/analysis.py. Read test_analysis_repository.py first for the mock-Supabase query-builder pattern.
- Step 1: Write failing tests
list_by_project builds an eq("project_id") + eq("organization_id") + range(offset, offset+limit-1) query and wraps into PaginatedList (assert total comes from response.count); attach_measurement_sources issues an upsert with ignore_duplicates semantics (idempotent); list_sources_for_measurement filters by cell_measurement_id. Follow the query-builder mocking in test_analysis_repository.py.
- Step 2: Run — verify fail
just test-backend tests/test_repositories/test_raw_data_repository.py -v
Expected: FAIL (module/methods not defined).
- Step 3: Implement
RawDataRepository
upsert(..., ignore_duplicates=True, on_conflict=...) kwargs against the installed supabase/postgrest version. If the kwarg names differ, adjust; the behavior required is “insert these pairs, silently skip ones that already exist.” Verify by grepping existing .upsert( uses in backend/src/.
- Step 4: Run — verify pass
just test-backend tests/test_repositories/test_raw_data_repository.py -v
Expected: PASS.
- Step 5: Commit
Task 4: Storage repository (TDD)
Files:- Create:
backend/src/repositories/raw_data_bucket.py - Test: covered via the service test (storage repo is thin); optional dedicated test mirroring
test_cell_data_bucket.py.
analysis_bucket.py + material_property_datasets_bucket.py.
- Step 1: Implement
RawDataBucketRepository
- Step 2: Import-check + commit
cd backend && uv run python -c "from src.repositories.raw_data_bucket import RawDataBucketRepository"
Task 5: Service (TDD)
Files:- Create:
backend/src/services/raw_data_service.py - Test:
backend/tests/test_services/test_raw_data_service.py
analysis_service.py. Read test_analysis_service.py for the AsyncMock fixture style (note build_path must be a MagicMock, not AsyncMock, since it’s sync).
- Step 1: Write failing tests
createinserts the DB row, uploads, returns the record; upload failure ⇒ row is deleted (rollback) and error re-raised.deleteremoves the DB row first, then best-effort file delete; a storage failure does NOT raise.get/update/deleteraiseNotFoundErrorwhen the record is missing ororganization_idmismatches.attachvalidates the measurement is in-org (elseNotFoundError) and every raw_data_id exists + is in-org (elseBadRequestErrornaming the id); then callsattach_measurement_sources.get_download_urlreturns the signed URL for the record’sstorage_path.
- Step 2: Run — verify fail
just test-backend tests/test_services/test_raw_data_service.py -v
Expected: FAIL.
- Step 3: Implement
RawDataService
CellMeasurement.organization_id is populated by get_by_id’s default select (*, users(email)). The measurements table derives org via join to cell_specifications; if .organization_id is not directly on the row, use the existing helper get_by_id_with_org or the select that includes org (grep organization_id in repositories/cell_measurements.py — there is an org-scoped select around line 170). Adjust the guard to use whatever attribute/[method] exposes the measurement’s org. If none is convenient, fall back to the repo’s existing org-scoped getter used by AnalysisService (it calls measurement_repo.get_by_id(...) then compares .organization_id, so the same attribute is available — verify).
- Step 4: Run — verify pass
just test-backend tests/test_services/test_raw_data_service.py -v
Expected: PASS.
- Step 5: Commit
Task 6: Routes + mounting (TDD)
Files:- Create:
backend/src/routes/raw_data.py - Modify:
backend/src/main.py - Test:
backend/tests/test_routes/test_raw_data_routes.py
backend/src/routes/analysis.py. Read test_analysis_routes.py for the route-test harness (dependency overrides + TestClient).
- Step 1: Write failing route tests
POST "" multipart create returns 201 + record with content_type/size_bytes derived server-side; GET "" requires project_id (422 without it) and paginates; GET /{id} 200/404; GET /{id}/download-url returns {"url": ...}; PATCH /{id} partial; DELETE /{id} 204. Use dependency overrides to inject a fake service.
- Step 2: Run — verify fail
just test-backend tests/test_routes/test_raw_data_routes.py -v
Expected: FAIL.
- Step 3: Implement the router
- Step 4: Mount in
main.py
app.include_router(analysis_router, prefix="/analyses")), add:
- Step 5: Run — verify pass
just test-backend tests/test_routes/test_raw_data_routes.py -v
Expected: PASS.
- Step 6: Commit
Task 7: Measurement-centric attach/detach routes (TDD)
Files:- Modify:
backend/src/routes/cell_measurements.py(add todirect_router) - Test: extend
backend/tests/test_routes/test_raw_data_routes.pyor a newtest_measurement_raw_data_routes.py.
RawDataService (Task 5) already has attach_to_measurement / detach_from_measurement / list_sources_for_measurement. These routes just call it, but are mounted under /cell_measurements for discoverability.
- Step 1: Write failing tests
POST /cell_measurements/{id}/raw_data with {"raw_data_ids": [...]} attaches and returns the source list; a bad id ⇒ 400; a foreign measurement ⇒ 404; re-attaching the same id is idempotent (still 200). DELETE /cell_measurements/{id}/raw_data/{raw_data_id} ⇒ 204. GET /cell_measurements/{id}/raw_data lists.
-
Step 2: Run — verify fail.
just test-backend tests/test_routes/test_measurement_raw_data_routes.py -v→ FAIL. -
Step 3a: Add the required imports to
cell_measurements.py
direct_router (line 53) already imports Query,
status, JSONResponse, jsonable_encoder, get_current_organization_id,
Annotated, Depends. It does NOT import these — add them:
- Step 3b: Add the routes to
direct_routerincell_measurements.py
direct_router has 17
routes; the new static-suffix /{measurement_id}/raw_data and
/{measurement_id}/raw_data/{raw_data_id} do not collide with any existing
route, and the only :path converter is scoped to filename in
/{measurement_id}/files/{filename:path}. No shadowing — no ordering fix
needed.
- Step 4: Run — verify pass. → PASS.
- Step 5: Commit
Task 8: Backend full-suite gate
- Step 1: Run the whole backend suite + prek
just test-backend then invoke the prek skill (pre-commit) on staged changes.
Expected: all green; API-convention and PATCH-vs-PUT hooks pass. Fix any failures before proceeding.
- Step 2 (optional, if local Supabase up): RLS integration
supabase status shows a running DB, add/extend a test under backend/tests/test_search/ (or the RLS test area) asserting org A cannot read org B’s raw_data rows/files. Run just test-search. If Supabase isn’t running, note it and skip (these auto-skip).
Task 9: SDK — RawData model + RawDataClient (TDD)
Files:
- Modify:
packages/ionworks-api/ionworks/models.py - Create:
packages/ionworks-api/ionworks/raw_data.py - Modify:
packages/ionworks-api/ionworks/client.py - Modify:
packages/ionworks-api/ionworks/__init__.py - Test:
packages/ionworks-api/tests/test_raw_data.py
packages/ionworks-api/ionworks/custom_model.py:180-248 (upload template), material.py:16-35 (sub-client boilerplate), models.py (model + _build_endpoint/_parse_list_response), and tests/test_measurement_types.py (mocked-HTTP test style).
- Step 1: Add the
RawDatamodel tomodels.py
RawData to the from .models import (...) block and __all__ in __init__.py.
- Step 2: Write failing SDK tests (
tests/test_raw_data.py)
test_measurement_types.py. Construct RawDataClient(MagicMock()). Assert:
-
upload(project_id, <path>, name=...)callsclient.upload_multipart("/raw_data", data={...}, files={...})withproject_id/name/metadataindataand a("file", ...)tuple infiles; returnsRawData(**resp). -
list(project_id)callsclient.getwith the endpoint carryingproject_idand parses aPaginatedList[RawData]. -
get,download_url(returnsresp["url"]),update(PATCH),delete. -
Step 3: Run — verify fail.
cd packages/ionworks-api && uv run python -m pytest tests/test_raw_data.py -v→ FAIL. -
Step 4: Implement
RawDataClient(ionworks/raw_data.py)
_parse_list_response (models.py:104-111) does model_class(**item) per item, so it CANNOT be used for plain-string ids — hence the direct PaginatedList(...) construction above. Confirm PaginatedList’s constructor kwargs (items/total/limit/offset) against models.py:25 and adjust names to whatever that class defines. The backend side is unaffected — PaginatedResponse[str] validates fine.
- Step 5: Wire the sub-client in
client.py
Ionworks.__init__ alongside the block at client.py:197-218:
- Step 6: Run — verify pass. → PASS.
- Step 7: Commit
Task 10: SDK — measurement attach/detach (TDD)
Files:-
Modify:
packages/ionworks-api/ionworks/cell_measurement.py -
Test: extend
packages/ionworks-api/tests/test_raw_data.pyor a new test file. - Step 1: Write failing tests
measurement.attach_raw_data(mid, [r1, r2]) POSTs /cell_measurements/{mid}/raw_data with {"raw_data_ids": [r1, r2]}; detach_raw_data(mid, r1) DELETEs /cell_measurements/{mid}/raw_data/{r1}; list_raw_data(mid) GETs and parses PaginatedList[RawData].
- Step 2: Run — verify fail. → FAIL.
-
Step 3: Implement on
CellMeasurementClient
cell_measurement.py (it already imports from .models; extend that import rather than adding a duplicate line).
- Step 4: Run — verify pass + full SDK suite
cd packages/ionworks-api && uv run python -m pytest -q
Expected: all green.
- Step 5: Commit
Task 11: Sync SDK skills
Files:packages/skills/skills/discover-api/SKILL.md, packages/skills/skills/upload-data/SKILL.md, and the dev-sync skill’s own file list.
- Step 1: Invoke the
ionworks-dev-syncskill
client.raw_data sub-client row + hierarchy to discover-api, and the raw-data upload + measurement attach/detach flow to upload-data. Keep edits within the skills’ own directories only (per memory: skill edits scope).
- Step 2: Commit
Task 12: Verify end-to-end + finalize
- Step 1: Full Python suite
just test-python (packages + backend). Expected: green.
- Step 2: Manual smoke via SDK against local backend (optional but recommended)
client.raw_data.upload(project_id, <tmpfile>) → list → download_url → create a measurement → measurement.attach_raw_data(mid, [rid]) → measurement.list_raw_data(mid) shows it → detach → gone. Confirm the file exists in the raw-data bucket at {org}/{id}/{filename}.
- Step 3: prek + open PR
prek on the whole diff; then use the git-workflow skill to open a PR titled feat(raw-data): org+project raw data storage (backend + SDK). Reference the spec. Apply the status: awaiting human review label via github-pr-labels.
Notes / Deferred (from spec §8)
- Table RLS reuses
cell_measurement:*; storage RLS usesstorage:*(every bucket does). Join-table RLS gates via the linkedraw_datarow’s org. Confirmed in Task 1. - Org-only RLS — project is a filter dimension, not a DB boundary (confirmed by user).
- No web UI, no auto-processing this phase.
- Org-guard verified:
cell_measurements.organization_idIS a real column (added20260429000001, NOT NULL in20260501000003);get_by_id’s*select populates it andAnalysisServiceuses the identicalmeasurement.organization_id != organization_idguard. The attach/detach guards in Task 5 are correct as written.