Skip to main content

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-data policies use storage: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_customupload_multipart), client.py:197-218 (sub-client wiring), client.py:405 (upload_multipart), models.py (pydantic extra="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; SDK packages/ionworks-api/tests/test_measurement_types.py.
Conventions (rules that will be enforced by pre-commit / review):
  • Repositories paginate with .select("*", count="exact") + .range(offset, offset+limit-1); return PaginatedList.
  • Services raise domain exceptions (NotFoundError, BadRequestError, ConflictError, AppError, ExternalServiceError), never HTTPException or bare ValueError. Updates use PATCH. organization_id never in URL path — comes from Depends(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.sqlraw_data table, measurement_raw_data_sources join table, indexes, trigger, table + storage RLS.
  • supabase/migrations/<ts>_create_raw_data_bucket.sql — the raw-data bucket INSERT.
  • supabase/seeds/buckets.sqlmodify: add raw-data to the seed list.
  • backend/src/pydantic_models/raw_data.pyRawData, UpdateRawData, AttachRawData, RawDataListResponse models.
  • backend/src/repositories/raw_data.pyRawDataRepository + join-table methods + dep.
  • backend/src/repositories/raw_data_bucket.pyRawDataBucketRepository + dep.
  • backend/src/services/raw_data_service.pyRawDataService + dep.
  • backend/src/routes/raw_data.py/raw_data CRUD router.
  • backend/tests/test_repositories/test_raw_data_repository.py
  • backend/tests/test_services/test_raw_data_service.py
  • backend/tests/test_routes/test_raw_data_routes.py
  • packages/ionworks-api/ionworks/raw_data.pyRawDataClient.
  • packages/ionworks-api/tests/test_raw_data.py
Modify:
  • backend/src/main.py — mount raw_data_router at /raw_data.
  • backend/src/routes/cell_measurements.py — add attach/detach/list-raw-data sub-routes on direct_router.
  • backend/src/services/cell_measurements.py (or the service backing those routes) — attach/detach/list orchestration.
  • packages/ionworks-api/ionworks/client.py — wire self.raw_data = RawDataClient(self).
  • packages/ionworks-api/ionworks/cell_measurement.py — add attach_raw_data / detach_raw_data / list_raw_data.
  • packages/ionworks-api/ionworks/models.py — add RawData model.
  • packages/ionworks-api/ionworks/__init__.py — export RawData.
  • SDK skills (discover-api, upload-data) via ionworks-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
Use a timestamp AFTER the latest existing migration. Get it with: ls supabase/migrations | sort | tail -1 then pick a larger YYYYMMDDHHMMSS.
  • Step 1: Write <ts>_add_raw_data.sql
Copy the idempotency scaffolding (the 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-data bucket
Append 4 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-data to the seed bucket list
In 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
Run: 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
Mirror backend/src/pydantic_models/analysis.py.
  • Step 1: Write the models
  • Step 2: Import-check
Run: 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
Mirror backend/src/repositories/analysis.py. Read test_analysis_repository.py first for the mock-Supabase query-builder pattern.
  • Step 1: Write failing tests
Cover: 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
Run: just test-backend tests/test_repositories/test_raw_data_repository.py -v Expected: FAIL (module/methods not defined).
  • Step 3: Implement RawDataRepository
Note for implementer: confirm the Supabase client’s 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
Run: 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.
Mirror analysis_bucket.py + material_property_datasets_bucket.py.
  • Step 1: Implement RawDataBucketRepository
  • Step 2: Import-check + commit
Run: 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
Mirror 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
Cover the ordering invariants and guards:
  1. create inserts the DB row, uploads, returns the record; upload failure ⇒ row is deleted (rollback) and error re-raised.
  2. delete removes the DB row first, then best-effort file delete; a storage failure does NOT raise.
  3. get / update / delete raise NotFoundError when the record is missing or organization_id mismatches.
  4. attach validates the measurement is in-org (else NotFoundError) and every raw_data_id exists + is in-org (else BadRequestError naming the id); then calls attach_measurement_sources.
  5. get_download_url returns the signed URL for the record’s storage_path.
  • Step 2: Run — verify fail
Run: just test-backend tests/test_services/test_raw_data_service.py -v Expected: FAIL.
  • Step 3: Implement RawDataService
Implementer note: confirm 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
Run: 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
Mirror backend/src/routes/analysis.py. Read test_analysis_routes.py for the route-test harness (dependency overrides + TestClient).
  • Step 1: Write failing route tests
Cover: 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
Run: 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
After the analyses include (app.include_router(analysis_router, prefix="/analyses")), add:
  • Step 5: Run — verify pass
Run: 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 to direct_router)
  • Test: extend backend/tests/test_routes/test_raw_data_routes.py or a new test_measurement_raw_data_routes.py.
The 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
Cover: 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
Verified against the file: 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_router in cell_measurements.py
Implementer note (route ordering — verified clean): 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
Run: 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
If 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
Read 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 RawData model to models.py
Export it: add RawData to the from .models import (...) block and __all__ in __init__.py.
  • Step 2: Write failing SDK tests (tests/test_raw_data.py)
Mirror test_measurement_types.py. Construct RawDataClient(MagicMock()). Assert:
  • upload(project_id, <path>, name=...) calls client.upload_multipart("/raw_data", data={...}, files={...}) with project_id/name/metadata in data and a ("file", ...) tuple in files; returns RawData(**resp).
  • list(project_id) calls client.get with the endpoint carrying project_id and parses a PaginatedList[RawData].
  • get, download_url (returns resp["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)
Implementer note: _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
In 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.py or a new test file.
  • Step 1: Write failing tests
Assert 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
Implementer note: match the existing import style in 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
Run: 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-sync skill
Follow its checklist to add the 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
Run: just test-python (packages + backend). Expected: green.
  • Step 2: Manual smoke via SDK against local backend (optional but recommended)
With the local backend + Supabase running, write a scratch script: create a project → client.raw_data.upload(project_id, <tmpfile>)listdownload_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
Invoke 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 uses storage:* (every bucket does). Join-table RLS gates via the linked raw_data row’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_id IS a real column (added 20260429000001, NOT NULL in 20260501000003); get_by_id’s * select populates it and AnalysisService uses the identical measurement.organization_id != organization_id guard. The attach/detach guards in Task 5 are correct as written.