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/totalshape (.claude/rules/repositories.md). - No unit tests for ops tasks (user preference).
- Run tests via
just, never bare pytest. Useuvfor Python.
File Structure
Create:supabase/migrations/20260728174630_add_project_id_to_protocols.sql— expand DDL (columns, FKs, indexes, project-awarefind_by_contentRPC).backend/src/ops_tasks/0022_seed_project_protocols.py— backfill: stamp catalog, copy into every existing project.backend/src/services/protocol_catalog.py— sharedcopy_catalog_protocols_into_projecthelper (used by both project creation and the ops task).backend/tests/test_services/test_protocol_catalog.py— helper unit tests.
backend/src/pydantic_models/experiments.py— addproject_id/access_leveltoExperimentTemplate;project_idtoExperiment.backend/src/repositories/experiment_templates.py—LIST_DEFAULT_COLUMNS, project_id filter on listing, project-awarefind_or_create_canonical_template.backend/src/repositories/experiments.py— 4-columnon_conflict, project_id in payload + finder.backend/src/services/project_service.py— seed catalog oncreate_project_with_admin.backend/src/routes/experiment_templates.py—project_idquery param on list; requireproject_idon create; stampaccess_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
created_by_email field in class ExperimentTemplate, add:
- Step 2: Add
project_idtoExperiment
class Experiment, after organization_id, add:
- Step 3: Verify import compiles
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:
DROP FUNCTION before the CREATE OR REPLACE.
Re-grant EXECUTE (required).DROP FUNCTIONremoves the 3-arg overload’s grants (baseline.sql:6393-6395), and because the new function has a different arg listCREATE OR REPLACEdoes not re-issue them. The RPC is called via the user client (experiment_templates.py:167,220), soauthenticatedneeds EXECUTE — Postgres grants EXECUTE to PUBLIC by default, but a hardened Supabase that revoked PUBLIC would break the call. Add, after theCREATE OR REPLACE:
- Step 2: Apply migration against local Supabase
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
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)
test_experiment_templates_conflict.py:
- Step 2: Run test to verify it fails
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_idparam and thread it through
find_or_create_canonical_template (experiment_templates.py:148):
-
Add
project_id: str | None = None,to the signature (afterorganization_id). -
Add
"in_project_id": project_id,torpc_params. -
In the
template_datadict for the create path, add: - Step 4: Run test to verify it passes
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
LIST_DEFAULT_COLUMNS (experiment_templates.py:16-20) to include the two new columns:
- Step 2: Add a project-scoped list method
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:
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
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_conflicton a trigger-set column (do this first)
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
upsertform 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_contentRPC (within_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.
- 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
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: confirmon_conflictcan target thecontent_hashcolumn even though the app never sets it (the trigger does). If PostgREST rejects anon_conflictarbiter on a column absent from the insert payload, fall back to a per-row existence check (find_by_contentRPC within_project_id) instead ofupsert. Decide this during implementation with a quick local test; keep the helper’s return contract identical.
- Step 4: Run test to verify it passes
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 syncClient, whereascopy_catalog_protocols_into_projectuses the asyncAsyncClient. 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 theon_conflict-on-content_hashdecision in Task 5 Step 3 forced a per-row existence check, mirror that same fallback here.
- Step 2: Verify prefix uniqueness + import
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
0018/0019 are invoked — likely uv run python -m src.ops_tasks.run 0020 or a just recipe). Then:
- 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
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
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
project_service.py, import the helper at top:
get_by_id returns the project, add:
Verified:self.service_user_project_role_repo.supabaseexists (set inBaseRepository.__init__,base.py:71) and is the service-roleAsyncClient(get_service_user_project_role_repositoryinjectsget_supabase_async_service_client). Use it directly — no constructor change needed.
- Step 4: Run tests to verify they pass
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 thecreate_experimentsignature. -
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_idto thefind_by_template_and_parametersfallback 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_idto the filters dict. - Step 3: Update callers
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
cd backend && uv run python -c "from src.repositories.experiments import ExperimentRepository; print('ok')"
Expected: ok.
- Step 5: Run experiment-related tests
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_idto the create body + require it
CreateExperimentTemplateBody add project_id: str | None = None. In create_experiment_template, at the top, raise if missing:
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_idquery param to the list route
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)
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:
- Add a required
project_idbody field to the parse-to-template request model (protocols.py);BadRequestErrorif missing. Pass it intoresolve_template_from_canonicalat:386. - Add
project_id: strtoresolve_template_from_canonical(:332) and pass it to_create_experiment_from_canonical. - Add
project_id: strto_create_experiment_from_canonical(:142) and pass it toCyclerProtocolService.create_experiment_from_protocol. - Add
project_idtocreate_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 samecycler_protocol_service.pycalls (:138/:153/:164). Do steps 3–4 here and Task 8 Step 3 as one coordinated edit tocreate_experiment_from_protocolsoproject_idreaches both the template-side find-or-create and the experiment-side upsert/finder in a single pass.
- Step 4: Run route tests
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
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
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)
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.envSUPABASE_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-workflowconventions; open PR titledfeat(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 task0022must be run per-environment.