Skip to main content

Protocols require project_id — PR 1 (expand) 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 nullable project_id scoping to protocols (experiment_templates) and their parameterisations (experiments), seed every project with copies of the built-in protocol catalog, and make reads/writes project-aware — all backward-compatible (expand phase). Architecture: Follow the optimization_templates/jobs access_level precedent. The existing system-org rows (organization_id = 00000000-0000-0000-0000-000000000001) become a hidden access_level='system' catalog. New projects get project-scoped copies synchronously on creation; existing projects are backfilled by an on_demand ops task. The canonical-dedup RPC and the experiments upsert arbiter are made project-aware (both would otherwise be correctness bugs). Reads return a project’s own rows when a project_id is in scope; the legacy org+system union stays for callers that don’t yet pass one. Tech Stack: FastAPI, Supabase (PostgREST + Postgres 15), Pydantic, pytest. just for tests, uv for Python. Spec: docs/superpowers/specs/2026-07-20-protocols-require-project-id-design.md. Scope: This plan is PR 1 only. PR 2 (contract: VALIDATE FKs, SET NOT NULL, delete catalog, remove system-org union) and PR 3 (project-membership RLS) are separate, time-gated efforts and are NOT planned here. Conventions to honour (from repo rules):
  • Migrations are DDL-only + idempotent (.claude/rules/supabase-migrations.md). Data changes → ops task.
  • Services raise domain exceptions, never HTTPException/ValueError (.claude/rules/fastapi-backend.md).
  • Repos paginate; list methods keep count/total shape (.claude/rules/repositories.md).
  • No unit tests for ops tasks (user preference).
  • Run tests via just, never bare pytest. Use uv for Python.

File Structure

Create:
  • supabase/migrations/20260728174630_add_project_id_to_protocols.sql — expand DDL (columns, FKs, indexes, project-aware find_by_content RPC).
  • backend/src/ops_tasks/0022_seed_project_protocols.py — backfill: stamp catalog, copy into every existing project.
  • backend/src/services/protocol_catalog.py — shared copy_catalog_protocols_into_project helper (used by both project creation and the ops task).
  • backend/tests/test_services/test_protocol_catalog.py — helper unit tests.
Modify:
  • backend/src/pydantic_models/experiments.py — add project_id / access_level to ExperimentTemplate; project_id to Experiment.
  • backend/src/repositories/experiment_templates.pyLIST_DEFAULT_COLUMNS, project_id filter on listing, project-aware find_or_create_canonical_template.
  • backend/src/repositories/experiments.py — 4-column on_conflict, project_id in payload + finder.
  • backend/src/services/project_service.py — seed catalog on create_project_with_admin.
  • backend/src/routes/experiment_templates.pyproject_id query param on list; require project_id on create; stamp access_level.
  • backend/tests/test_services/test_project_service.py — seed-on-create + idempotency tests.
  • backend/tests/test_repositories/test_experiment_templates_conflict.py — project-aware RPC threading test.

Task 1: Add project_id / access_level to the Pydantic models

Files:
  • Modify: backend/src/pydantic_models/experiments.py
  • Step 1: Add fields to ExperimentTemplate
After the created_by_email field in class ExperimentTemplate, add:
  • Step 2: Add project_id to Experiment
In class Experiment, after organization_id, add:
  • Step 3: Verify import compiles
Run: cd backend && uv run python -c "from src.pydantic_models.experiments import ExperimentTemplate, Experiment; print('ok')" Expected: prints ok.
  • Step 4: Commit

Task 2: Expand migration (columns, FKs, indexes, project-aware RPC)

Files:
  • Create: supabase/migrations/20260728174630_add_project_id_to_protocols.sql
DDL-only + idempotent. The access_level='system' data stamp lives in the ops task (Task 6), NOT here.
  • Step 1: Write the migration
Note: this CREATE OR REPLACE changes the function signature (adds a param). Because the arg list differs, Postgres creates a new overload rather than replacing the 3-arg one. Drop the old overload explicitly so only the 4-arg version remains:
Place the DROP FUNCTION before the CREATE OR REPLACE.
Re-grant EXECUTE (required). DROP FUNCTION removes the 3-arg overload’s grants (baseline.sql:6393-6395), and because the new function has a different arg list CREATE OR REPLACE does not re-issue them. The RPC is called via the user client (experiment_templates.py:167,220), so authenticated needs EXECUTE — Postgres grants EXECUTE to PUBLIC by default, but a hardened Supabase that revoked PUBLIC would break the call. Add, after the CREATE OR REPLACE:
  • Step 2: Apply migration against local Supabase
Run: supabase migration up (from repo root; local stack already running per session setup). Expected: applies with no error; existing system-org rows now have access_level='system', project_id NULL (satisfies the CHECK).
  • Step 3: Verify the CHECK holds and RPC signature swapped
Run:
Expected: columns present; exactly one find_by_content overload with 4 args. (If $SUPABASE_DB_URL is unset, use the worktree’s postgresql://postgres:[email protected]:54322/postgres.)
  • Step 4: Commit

Task 3: Project-aware canonical dedup in the templates repo

Files:
  • Modify: backend/src/repositories/experiment_templates.py
  • Test: backend/tests/test_repositories/test_experiment_templates_conflict.py
  • Step 1: Write the failing test (RPC receives project_id)
Add to test_experiment_templates_conflict.py:
  • Step 2: Run test to verify it fails
Run: just test-backend tests/test_repositories/test_experiment_templates_conflict.py::test_find_or_create_threads_project_id_into_rpc -v Expected: FAIL — find_or_create_canonical_template has no project_id param.
  • Step 3: Add project_id param and thread it through
In find_or_create_canonical_template (experiment_templates.py:148):
  • Add project_id: str | None = None, to the signature (after organization_id).
  • Add "in_project_id": project_id, to rpc_params.
  • In the template_data dict for the create path, add:
  • Step 4: Run test to verify it passes
Run: just test-backend tests/test_repositories/test_experiment_templates_conflict.py -v Expected: PASS (including the existing race test — confirm rpc_params reuse still works).
  • Step 5: Commit

Task 4: Project-aware listing + LIST_DEFAULT_COLUMNS

Files:
  • Modify: backend/src/repositories/experiment_templates.py
  • Step 1: Add new columns to LIST_DEFAULT_COLUMNS
Change LIST_DEFAULT_COLUMNS (experiment_templates.py:16-20) to include the two new columns:
  • Step 2: Add a project-scoped list method
Add a method that lists a single project’s protocols. Mirror the exact projection idiom from get_templates_by_organization_with_email (experiment_templates.py:124-128) — there is no _build_select_columns helper; the projection is built inline. Copy that block verbatim and add the project/access filters:
Note the row mapping uses the existing self._extract_created_by_email(row) (as the org method does), not self.model_class(**row) directly — the users(email) join needs flattening.
  • Step 3: Verify it imports/compiles
Run: cd backend && uv run python -c "from src.repositories.experiment_templates import ExperimentTemplateRepository; print('ok')" Expected: ok.
  • Step 4: Commit

Task 5: Shared catalog-copy helper

Files:
  • Create: backend/src/services/protocol_catalog.py
  • Test: backend/tests/test_services/test_protocol_catalog.py
  • Step 0: GATING — validate on_conflict on a trigger-set column (do this first)
The whole seed path (this helper + Task 6 ops task + Task 7 create) hinges on whether PostgREST accepts an upsert whose on_conflict arbiter names content_hash — a column set by the BEFORE-INSERT trigger and absent from the insert payload. No existing code in this repo does this, so validate it once, locally, before writing the helper, and record the decision in this step: Run a throwaway script against the local stack (a project must already have a project_id; the catalog rows must exist):
  • If the upsert succeeds and the replay is a no-op → use the upsert form in Step 3 (and Task 6). Record: “Decision: on_conflict upsert works.”
  • If PostgREST rejects the arbiter → use the per-row fallback: for each catalog row, call the project-aware experiment_templates_find_by_content RPC (with in_project_id); if it returns a row, skip; else insert. This is the same idempotency guarantee, just explicit. Record: “Decision: per-row existence-check fallback.” Then write Step 3 and Task 6 in that form.
Do not proceed to Step 1 until this is decided and recorded.
  • Step 1: Write the failing test
Adjust the mock-chaining to match the actual PostgREST fluent calls you write in Step 3 (read = .select(...).eq("access_level","system").eq(...); write = .upsert(..., on_conflict=..., ignore_duplicates=True)). Keep the assertions.
  • Step 2: Run test to verify it fails
Run: just test-backend tests/test_services/test_protocol_catalog.py -v Expected: FAIL — module does not exist.
  • Step 3: Implement the helper
Verify before relying on it: confirm on_conflict can target the content_hash column even though the app never sets it (the trigger does). If PostgREST rejects an on_conflict arbiter on a column absent from the insert payload, fall back to a per-row existence check (find_by_content RPC with in_project_id) instead of upsert. Decide this during implementation with a quick local test; keep the helper’s return contract identical.
  • Step 4: Run test to verify it passes
Run: just test-backend tests/test_services/test_protocol_catalog.py -v Expected: PASS.
  • Step 5: Commit

Task 6: Backfill ops task

Files:
  • Create: backend/src/ops_tasks/0022_seed_project_protocols.py
No unit test (ops-task preference). Verify by running locally.
  • Step 1: Write the ops task
Consistency note: the ops-task client is the sync Client, whereas copy_catalog_protocols_into_project uses the async AsyncClient. Rather than bridge event loops, this task inlines the same insert-only logic synchronously in _seed_one (kept deliberately identical in shape to the helper). If the on_conflict-on-content_hash decision in Task 5 Step 3 forced a per-row existence check, mirror that same fallback here.
  • Step 2: Verify prefix uniqueness + import
Run: cd backend && uv run python -c "import importlib; importlib.import_module('src.ops_tasks.0022_seed_project_protocols')" 2>/dev/null || echo "check module loader" — ops tasks are numeric-prefixed; confirm the repo’s ops-task runner discovers 0020_*. Also run the pre-commit “Ops task prefixes are unique” hook implicitly on commit. Expected: no duplicate-prefix error.
  • Step 3: Run the ops task locally and verify idempotency
Run it via the repo’s ops-task runner (check how 0018/0019 are invoked — likely uv run python -m src.ops_tasks.run 0020 or a just recipe). Then:
Expected: each project has the catalog count. Run the task a second time → counts unchanged (idempotent).
  • Step 4: Commit

Task 7: Seed protocols on project creation

Files:
  • Modify: backend/src/services/project_service.py
  • Test: backend/tests/test_services/test_project_service.py
  • Step 1: Write the failing tests
Add to test_project_service.py. The service needs a service Supabase client to read catalog rows; check how ProjectService currently obtains a service client (it has service_user_project_role_repo — the underlying client is reachable via .supabase). If not cleanly reachable, add a service_client constructor param. Test both seed-happens and failure-surfaces:
Match the existing happy-path test’s mock wiring exactly (project_repo.create_project, project_role_repo.get_role_by_name returning a role with .id, service_user_project_role_repo.create_user_project_role, project_repo.get_by_id). Copy that setup rather than reinventing it.
  • Step 2: Run tests to verify they fail
Run: just test-backend tests/test_services/test_project_service.py -v -k "seed" Expected: FAIL — seeding not wired in.
  • Step 3: Wire seeding into create_project_with_admin
In project_service.py, import the helper at top:
After the Project Admin role is successfully assigned and before get_by_id returns the project, add:
Verified: self.service_user_project_role_repo.supabase exists (set in BaseRepository.__init__, base.py:71) and is the service-role AsyncClient (get_service_user_project_role_repository injects get_supabase_async_service_client). Use it directly — no constructor change needed.
  • Step 4: Run tests to verify they pass
Run: just test-backend tests/test_services/test_project_service.py -v Expected: PASS (existing tests still green).
  • Step 5: Commit

Task 8: Experiments upsert + finder become project-aware

Files:
  • Modify: backend/src/repositories/experiments.py
  • Step 1: Update create_experiment
  • Add project_id: str | None = None, to the create_experiment signature.
  • Add to experiment_data: if project_id: experiment_data["project_id"] = project_id.
  • Change the arbiter to on_conflict="template_id,parameters,organization_id,project_id".
  • Pass project_id to the find_by_template_and_parameters fallback call.
  • Step 2: Update find_by_template_and_parameters
  • Add project_id: str | None = None, to the signature.
  • Add if project_id: filters["project_id"] = project_id to the filters dict.
  • Step 3: Update callers
Grep for create_experiment( and find_by_template_and_parameters( across the backend and thread the parent template’s project_id where available (the experiments service and the simulate/evaluate processors). Where a caller has no project context yet during expand, leaving it None keeps the legacy org-global behaviour. Run: grep -rn "create_experiment(\|find_by_template_and_parameters(" backend/src Known caller to update: backend/src/services/cycler_protocol_service.py calls find_or_create_canonical_template (:138), find_by_template_and_parameters (:153), and create_experiment (:164) in create_experiment_from_protocol (:77) — thread project_id through all three there (both the template-side find-or-create AND the experiment-side calls), so a protocol parsed via this path lands in the right project. Confirm create_experiment_from_protocol has a project in scope; if not, it stays on the legacy None path for expand.
  • Step 4: Compile check
Run: cd backend && uv run python -c "from src.repositories.experiments import ExperimentRepository; print('ok')" Expected: ok.
  • Step 5: Run experiment-related tests
Run: just test-backend tests/ -v -k "experiment" Expected: PASS.
  • Step 6: Commit

Task 9: Route — require project_id on create, add project_id list filter

Files:
  • Modify: backend/src/routes/experiment_templates.py
  • Step 1: Add project_id to the create body + require it
In CreateExperimentTemplateBody add project_id: str | None = None. In create_experiment_template, at the top, raise if missing:
Import BadRequestError from src.exceptions. Thread body.project_id into both the force_create template_data (add "project_id" and "access_level": "project") and the find_or_create_canonical_template(...) call (pass project_id=body.project_id).
  • Step 2: Add project_id query param to the list route
Add project_id: Annotated[str | None, Query()] = None to list_experiment_templates. When project_id is provided, return that project’s protocols via get_templates_by_project_with_email(organization_id, project_id, include=include_set, ...) and skip the system-org union. When absent, keep the existing org+system union (backward compatible). Preserve the HIDDEN_TEMPLATE_NAMES filter and the ExperimentTemplateListResponse shape.
  • Step 3: Also require project_id on parse-to-template (5-layer chain)
The parse chain is 5 layers deep, all in backend/src/simulation/services/simulation_service.py except the last:
resolve_template_from_canonical does not call find_or_create_canonical_template directly — two methods sit between them. Threading project_id requires editing four signatures, not two:
  1. Add a required project_id body field to the parse-to-template request model (protocols.py); BadRequestError if missing. Pass it into resolve_template_from_canonical at :386.
  2. Add project_id: str to resolve_template_from_canonical (:332) and pass it to _create_experiment_from_canonical.
  3. Add project_id: str to _create_experiment_from_canonical (:142) and pass it to CyclerProtocolService.create_experiment_from_protocol.
  4. Add project_id to create_experiment_from_protocol (:77) and thread it into its three internal calls (:138/:153/:164).
Cross-reference with Task 8 Step 3, which independently lists the same cycler_protocol_service.py calls (:138/:153/:164). Do steps 3–4 here and Task 8 Step 3 as one coordinated edit to create_experiment_from_protocol so project_id reaches both the template-side find-or-create and the experiment-side upsert/finder in a single pass.
  • Step 4: Run route tests
Run: just test-backend tests/test_routes/test_experiment_templates_list.py tests/test_routes/test_experiment_templates_conflict.py -v Expected: PASS. Add a test asserting missing project_id on create → 400 BAD_REQUEST, and a test asserting the project-filtered list excludes another project’s row (exclusion assertion per repo rule).
  • Step 5: Commit

Task 10: Frontend caller audit + full test pass

Files:
  • Audit only (frontend); no changes expected if PR #1225 already supplies project context.
  • Step 1: Audit create/parse callers for project_id
Run: grep -rn "experiment_templates\|parse-to-template\|experimentTemplate" frontend/src --include="*.ts" --include="*.tsx" | grep -iE "post|create|parse" Confirm each create/parse call site sends a project_id. If any straggler doesn’t (and is a legitimate projectless caller), it stays on the legacy org path — but a create call without project_id will now 400, so fix those to pass the in-project project_id. Report findings; make minimal frontend edits only where a create/parse call is missing project_id.
  • Step 2: Full backend test suite
Run: just test-backend -v Expected: green. Investigate any failure touching experiments/templates/projects.
  • Step 3: Frontend unit + type check (if any frontend edits were made)
Run: just test-frontend-unit and cd frontend && npx tsc --noEmit Expected: green.
  • Step 4: Commit any frontend fixes

Task 11: Browser verification of the in-project protocol flow

  • Step 1: Start servers + log in per CLAUDE.local.md (preview_start frontend/backend, inject auth token).
  • Step 2: Open a project’s protocol list, confirm the seeded catalog protocols appear.
  • Step 3: Create a protocol in the project, confirm it saves and appears in that project only (open a second project, confirm it is NOT there — exclusion check).
  • Step 4: Screenshot the seeded list + the created protocol as proof.
Worktree gotchas (from memory): vite --port N --strictPort; backend .env SUPABASE_URL must point at the running stack (54321/54322); if jobs run, RAY_DASHBOARD_PORT=8284.

Final: open the PR

  • Run the github-pr-labels + git-workflow conventions; open PR titled feat(protocols): scope protocols to project_id (expand) describing the expand phase and linking the spec. Note the two follow-ups (contract PR ≥1mo later; RLS PR after that). Reference that ops task 0022 must be run per-environment.