Protocols require project_id
Date: 2026-07-20 Status: Approved (design) Builds on: PR #1225 (moves the protocol simulator into projects) and PR #1206 (scopes materials/components to project — the pattern this mirrors).Problem
“Protocols” (stored asexperiment_templates) and their concrete
parameterisations (experiments) are currently scoped only by
organization_id. There is no project scoping. “System protocols” (the built-in
Cycler Protocol / standard charge-discharge templates) are represented as rows
owned by the system organization 00000000-0000-0000-0000-000000000001 and
merged into every org’s listing via a union in the list route plus an RLS
allowance.
Now that PR #1225 has moved the protocol simulator inside projects, protocols
should belong to a project — the same move materials made in PR #1206. The
end state: every protocol carries a project_id; “system protocols” are no
longer a user-facing shared library but a hidden internal catalog that is
copied into each project on creation, so each project starts with its own
editable set of the standard protocols.
Goals
experiment_templatesandexperimentseach carry aproject_id.- New projects are auto-seeded with copies of the standard protocol set.
- Existing projects are backfilled with the same set.
- “System protocols” stop being a merged read-time library and become an internal copy source.
- Rolled out with the repo’s expand-and-contract discipline:
project_idis additive/nullable first, madeNOT NULLonly after the backfill has run in every environment.
Non-goals
- Changing the protocol/UCP content of the standard protocols.
- Migrating
optimization_templates(already hasproject_id+access_level). - Any frontend work beyond what PR #1225 already delivered (the in-project simulator already passes a project context).
Current state (as explored)
experiment_templates(baseline.sql:842):organization_id NOT NULL, noproject_id, no system flag. Dedup unique indexidx_experiment_templates_content_hash_org (content_hash, organization_id)(baseline.sql:3593).content_hashis set by a BEFORE INSERT/UPDATE trigger (trg_experiment_templates_set_content_hash,baseline.sql:3973).experiments(baseline.sql:2165):template_id NOT NULL,organization_id NOT NULL, noproject_id. Unique indexexperiments_template_params_org_unique (template_id, parameters, organization_id)(baseline.sql:3441).- System protocols = rows with
organization_id = SYSTEM_ORGANIZATION_ID (00…001). Exposed via: RLS SELECT allowance (baseline.sql:5872), the list-route union of org + system templates (routes/experiment_templates.py:117-126), and the get-by-id service fallback to a service-client repo (services/experiment_templates.py:16,104-149). - Sibling precedent for an explicit “system” concept:
optimization_templates(baseline.sql:2316) andjobs(baseline.sql:2201) both useproject_id uuid(nullable) +access_level textCHECK, with a CHECK thataccess_level='project' ⇒ project_id NOT NULL. - Materials precedent (PR #1206): migration
20260715000001_add_project_id_to_materials.sql(nullableproject_id, composite FKNOT VALID, project-awareNULLS NOT DISTINCTunique index) + ops task0018_scope_materials_to_project.py(on_demandclaim-or-copy backfill) + a deferredNOT NULLcontract migration. - Seed hook:
ProjectService.create_project_with_admin(services/project_service.py:48) creates the project and assigns the Project Admin role; this is where protocol seeding is added.
Design decisions (agreed)
- System protocols → auto-copied into every project. Not a read-only global library, not copy-on-first-use. Each project gets its own editable copies.
- Catalog is the source of truth. Keep the existing system-org rows as a
hidden internal catalog (
access_level='system'), never project-scoped, never user-visible in project reads. Copy from it on project create + backfill. - Scope both tables.
experiment_templatesANDexperimentsgetproject_id. access_levelcolumn (likeoptimization_templates/jobs), not the materialsNULLS NOT DISTINCTorg-global hack — self-documenting and the CHECK enforces the invariant.- Seed synchronously inside
create_project_with_admin(a handful of INSERTs; matches how role assignment already runs there), not via the task queue. - Two PRs: expand now, contract in ~1 month, mirroring materials.
Section 1 — Schema (expand phase, PR 1)
Migration~20260728174630_add_project_id_to_protocols.sql. All DDL idempotent
and additive.
experiment_templates:
ADD COLUMN IF NOT EXISTS project_id uuid(nullable in expand).ADD COLUMN IF NOT EXISTS access_level text NOT NULL DEFAULT 'system'withCHECK (access_level IN ('system','project')). The default is'system', not'project'— this is load-bearing. The existing rows this migration runs against are the system-org catalog withproject_id NULL. Defaulting to'project'would label themprojectwhile theirproject_idis still NULL, which violates the invariant CHECK below and fails the migration on any DB that already has those rows. Defaulting to'system'labels them correctly on add; the seed/backfill path then insertsaccess_level='project'rows explicitly. (New project-scoped writes always set'project'explicitly, so the column default is only ever hit by the catalog.)- Invariant CHECK (mirrors
jobs_access_level_project_id_checkatbaseline.sql:2205, matching its parenthesisation exactly):Note this is stronger than thejobs/optimization_templatesmirror: it also forbids asystemrow from carrying aproject_id, so the catalog invariant (“system ⇒ project_id NULL”) is actually enforced by the constraint, not just by convention. The dedup index’s single-system-slot reasoning (below) depends on this. - Composite FK
(project_id, organization_id) → projects(id, organization_id) ON DELETE CASCADE, addedNOT VALID(existing rows are NULL/system, exempt; VALIDATE in contract phase). CASCADE: a project’s protocols die with it.NOT VALIDonly skips validating existing rows — the cascade fires on new DML immediately, which is why every dependent row must end up in the same project as the protocol it references (see the board re-pointing below); otherwise deleting one project reaches sideways into another’s data. - Replace the dedup index with a project-aware one:
DROPthe org-wide unique index,CREATE UNIQUE INDEX … ON (content_hash, organization_id, project_id) NULLS NOT DISTINCT. Each project holds its own copy; the single system slot (project_id NULL) stays deduped; the fixed column list stays targetable by PostgRESTon_conflictupsert. CREATE INDEX IF NOT EXISTS idx_experiment_templates_project_id.
project_id breaks the existing canonical
lookup, which keys on (content_hash, organization_id) only:
- The SQL function
experiment_templates_find_by_content(baseline.sql:900-909) filters oncontent_hash = … AND organization_id = …withLIMIT 1and noORDER BY. After the backfill, every project’s copy of a catalog protocol has the samecontent_hashandorganization_id, so this returns an arbitrary project’s row. find_or_create_canonical_template(repositories/experiment_templates.py:148-230) then mayupdate()that row’sname/created_by(lines 187-192) — a cross-project mutation — and the 23505 race re-lookup (lines 219-221) has the same defect.
in_project_id, filtering
project_id IS NOT DISTINCT FROM) works, but migrations run before the app
rolls out, so between the two the deployed backend calls a signature that no
longer exists. Instead:
- Leave
experiment_templates_find_by_contentuntouched and unused; the contract migration drops it. find_or_create_canonical_templatecalls the unchanged 2-arggenerate_experiment_template_content_hashRPC — the same function the insert trigger uses, so app-side and stored hashes cannot disagree — then filters on(content_hash, organization_id, project_id)itself. Theproject_id is Nonebranch must useIS NULL, not equality, to keep matching the catalog slot.- Thread
project_idthroughfind_or_create_canonical_template(accept it as a param, narrow the lookup with it, and include it in thecreate()payload withaccess_level='project').
experiments:
ADD COLUMN IF NOT EXISTS project_id uuid(nullable in expand).- Composite FK
(project_id, organization_id) → projects(id, organization_id) ON DELETE CASCADE,NOT VALID. - Replace
experiments_template_params_org_uniquewith project-aware(template_id, parameters, organization_id, project_id) NULLS NOT DISTINCT. CREATE INDEX IF NOT EXISTS idx_experiments_project_id.- No
access_level— experiments are never “system”; each inherits its template’s project.
ExperimentRepository.create_experiment
(repositories/experiments.py:86-89) hardcodes
on_conflict="template_id,parameters,organization_id". PostgREST requires the
on_conflict column list to match an existing unique constraint; once the index
gains project_id, the 3-column arbiter no longer matches any unique index and
the upsert errors. In the same PR:
- Change the arbiter to
on_conflict="template_id,parameters,organization_id,project_id". - Add
project_idto thecreate_experimentpayload (inherited from the parent template) and to thefind_by_template_and_parametersfallback filter (repositories/experiments.py:36-42).
'system' (see Section 1), the add already labels them correctly, so no data
UPDATE is strictly required by the current data. The backfill ops task still
defensively re-stamps access_level='system' WHERE organization_id = 00…001
(idempotent, cheap) to cover any environment that acquired a stray non-system
label. Any data write stays in the ops task, not the migration, per the
supabase-migrations rule. Catalog rows keep organization_id = 00…001,
project_id NULL.
Section 2 — Seed-on-create + backfill (PR 1)
Shared helpercopy_catalog_protocols_into_project(service_client, org_id, project_id, created_by=None):
- Reads catalog rows (
access_level='system'). - Inserts a project-scoped copy of each: same
name,protocol_config,parameters_schema,time_series_spec,metrics_spec,plot_options,description,source_protocol;organization_id = org_id,project_id = project_id,access_level='project', freshid.content_hashis recomputed by the existing trigger. - Idempotent: find-or-insert per catalog row — skip if a same-
content_hashrow already exists for(org, project)(guaranteed by the project-aware unique index; useon_conflictno-op or an explicit existence check). - Uses the service client because catalog rows live in the system org and a user client can’t read them (mirrors the service-repo usage already in the template service).
create_project_with_admin:
- After the project + Project Admin role are created, call the helper synchronously with the service client.
- A failure surfaces as
AppError, consistent with the method’s existing role-assignment failure handling.
0023_seed_project_protocols.py, RUN_ON="on_demand",
batch 500:
-
Stamp
access_level='system'on the existing system-org catalog rows (the one data step the migration can’t do). -
Adopt each org’s legacy unscoped protocols (real
organization_id,project_idNULL — protocols were org-wide before this change; prod has ~500 across 25 orgs). Insert-only seeding is not enough: these rows are invisible to the project-scoped list and would block the contractSET NOT NULL. For each row, copy it into all the org’s projects except the oldest, then claim the row itself for the oldest project. Claiming in place (rather than copy-then- delete) keeps its id, soexperiments/simulationsreferences survive; the copies preserve the org-wide visibility users had before. Copy-before-claim is what makes an interrupted run replay-safe — the row stays unscoped, and so re-selected, until its copies exist. - Then seed the catalog into every project — after adoption, so a legacy protocol whose content matches a catalog protocol claims that project’s slot first and the catalog upsert skips it as a duplicate instead of colliding.
-
Then backfill
experiments.project_idfrom each experiment’s template. -
Then re-point every table that carries its own
project_idand references a protocol, at the copy inside its own project. There are two:simulation_boards.template_idandplanned_measurements.protocol_id. A row pointing at another project’s protocol (its org’s oldest, where the formerly org-wide protocol was homed) or at the catalog breaks project deletion, and the two FKs break it in opposite directions:simulation_boards.template_idisON DELETE CASCADE— deleting the other project silently deletes boards belonging to untouched projects. Prod: 40 of 166 boards sit outside the home project, 135 point at the catalog.planned_measurements.protocol_idisON DELETE RESTRICTandNOT NULL— the delete is refused instead, so deleting a project fails outright with an FK error and the contract phase cannot delete the catalog rows at all. Prod exposure is currently zero (all 34 rows already sit in the home project), but that is a property of today’s data, not an invariant.
enforce_template_id_immutability, the BEFORE-UPDATE trigger that raises on anysimulation_boards.template_idchange; the guard predates project scoping and is what makes these references unfixable. It is replaced, not dropped:simulation_boardsis granted toauthenticatedand the UPDATE policy checks project permission but not columns, so the trigger was the only thing preventing a direct PostgRESTPATCHfrom re-pointing a board at another project’s protocol — re-creating the very cross-project reference this task erases. The replacement,enforce_template_id_same_project, changes the rule from “never re-point” to “may only reference a protocol in the same project” — strictly stronger, and satisfied by this task’s re-pointing, so it never has to come off. It also permits references to unscoped (project_id IS NULL) catalog/legacy rows, which is what lets it be installed during expand while the backfill is still in flight; the contract phase’sNOT NULLremoves that branch’s reach.planned_measurementshas no such guard (only amoddatetimetrigger, so re-pointing does bumpupdated_at). -
An experiment’s project comes from where it is used —
experiments←simulations←study_simulation_mappings→studies.project_id— not from its template. Inheriting the template’s project would drop every legacy experiment into its org’s oldest project, and since the study view matches boards to simulations by exacttemplate_id, a board re-pointed to its own project would then never match its simulations and would render empty. The experiment is re-pointed at the chosen project’s protocol copy too. Falls back to the template’s project, then the org’s oldest, when no study names it. - Orgs with zero projects can’t be adopted; log them as warnings. The contract migration must not run until every environment reports zero.
-
Idempotent;
on_demandso it’s run/reviewed per environment. - No unit test (per the no-ops-task-tests preference).
Section 3 — Read/write path (PR 1) and contract (PR 2)
Reads (expand — dual-source, backward compatible):- When a
project_idis in scope (the in-project protocol list, post-1225), return that project’s rows only (access_level='project',project_id=<project>). No system-org union — each project has its own copies. - The legacy org-wide union (org rows + system-org rows) stays only for callers
not yet passing a project, so nothing breaks mid-rollout.
HIDDEN_TEMPLATE_NAMESfilter unaffected. ExperimentTemplateRepository/ExperimentRepositorygainproject_idfiltering.- Add
project_idandaccess_leveltoLIST_DEFAULT_COLUMNS(repositories/experiment_templates.py:18-21) so list views can distinguish project-scoped rows without aninclude=round-trip.
create_experiment_templateandPOST /protocols/parse-to-templaterequire aproject_idand stampaccess_level='project'.create_experimentstamps the parent template’sproject_id.project_idis resolved from the request body/path (never a URL org, per the fastapi-backend rule). Missingproject_idon a project-scoped create →BadRequestErrorwith an actionable message.
project_id required on those endpoints. Before PR 1 merges, audit
every caller of POST /experiment_templates, POST /protocols/parse-to-template,
and experiment creation to confirm a project_id is supplied; any straggler that
doesn’t will now get a BadRequestError. If a legitimate projectless caller
remains, keep it on the legacy org-wide path for the expand window rather than
forcing a project.
Access model: protocols are project-private (intended end state), but RLS
enforcement is deliberately deferred. Decision confirmed with the product
owner: the target model is that a protocol belongs to a project and only project
members should read it — the same model optimization_templates enforces
(project reads gated on has_permission_via_project). This matters because
project membership is strictly narrower than org membership:
create_project_with_admin grants a user_project_role only to the project
creator (project_service.py), so an org member is not automatically a member of
every project in that org.
However, the DB-layer RLS for that model is not built in this effort (neither
PR 1 nor PR 2). During the expand/contract window, project narrowing is enforced
in the app/repository layer (the project filter on reads), while RLS stays
org-permission-only (has_permission_in_org, baseline.sql:5783/5803/5857).
Consequences to be explicit about:
- This does not reduce today’s protection: protocols are currently org-wide, so any org member can already read all of them. The app-layer project filter is a new narrowing, just not a DB-enforced one yet.
- Until the deferred RLS lands, the project-privacy guarantee is app-layer only —
a caller that reaches
experiment_templates/experimentswith a user JWT but without the app’s project filter (a raw PostgREST query, a future endpoint that forgets the filter) could read another project’s protocols. This is an accepted, temporary gap, not the end state. - The system-org SELECT allowance (
baseline.sql:5872) stays through expand (harmless — the app no longer unions the catalog into project views) and is removed in the contract phase with the catalog rows.
experiment_templates and experiments —
access_level='project' reads/writes gated on has_permission_via_project,
mirroring optimization_templates (baseline.sql:5865). This closes the
app-layer-only gap above. It is deliberately sequenced after the expand +
contract migrations so the RLS predicates apply to a fully-backfilled,
NOT NULL-project_id table. Called out here so it is not lost.
Contract phase (PR 2, ~1 month later, gated on the backfill running
everywhere):
-
VALIDATEthe composite FKs. -
SET NOT NULLonexperiment_templates.project_idandexperiments.project_id. -
Drop
experiments_template_params_org_unique, the old 3-column unique index. It is deliberately left in place by the expand migration: the deployed backend’screate_experiment/ batch upsert use it as theiron_conflictarbiter, and dropping it before that code ships 42P10s every write. Keeping it is only safe once the backfill has run. Before that, an existing experiment withproject_idNULL and a new project-scoped insert for the same(template_id, parameters, organization_id)do not conflict on the 4-column arbiter (NULL is a distinct value), soDO NOTHINGcannot suppress the insert and the 3-column index rejects it with a 23505 — reproduced against a live DB. That is why ops task 0023ispre_deployrather thanon_demand`: it closes the window inside the deploy, with old code serving until it completes. -
Delete the
access_level='system'catalog rows (ops task). Only safe because ops task 0023re-points everyexperiments,simulation_boardsandplanned_measurementsrow off the catalog first.experimentsandsimulation_boardscascade — on prod this would otherwise destroy 135 of 166 simulation boards — whileplanned_measurements` restricts, which would make the deletion fail outright. Re-verify all three are clear before running it. -
Replace the protocol-seeding source in
create_project_with_admin. Deleting the catalog turnscopy_catalog_protocols_into_projectinto a silent no-op (it returns 0 without raising), so new projects would be created with zero protocols. The expand phase logs a warning on a zero count to make that visible; the contract PR must remove or repoint the call. -
Tighten
enforce_template_id_same_projectby dropping itst.project_id IS NULLbranch. That branch exists only so the guard can be installed during expand, while boards may still legitimately point at an unscoped catalog/legacy protocol mid-backfill. Onceexperiment_templates.project_idisNOT NULLand the catalog rows are gone, no protocol can have a NULLproject_id, so the branch is unreachable — but leave nothing implicit: drop it so the invariant reads as “same project, always”. -
Remove the system-org fallback code:
SYSTEM_ORGANIZATION_ID,get_service_experiment_template_repository, and the list-route union (routes/experiment_templates.py:117-126). -
Drop the system-org SELECT allowance from the RLS policy
(
baseline.sql:5872) — the catalog rows are gone, so theorganization_id = 00…001disjunct is dead. -
Drop
NULLS NOT DISTINCTfrom the dedup indexes (no NULL slot left) → plain(content_hash, organization_id, project_id)and(template_id, parameters, organization_id, project_id). -
Keep the
access_levelcolumn (all'project') for symmetry withoptimization_templates— cheaper than dropping it.
Testing
- Migration: applies cleanly on a DB with existing data; project-aware dedup index enforces per-project uniqueness and a single system slot.
project_serviceunit test: creating a project seeds N catalog protocols into it (service-client mock); re-invoking the helper is a no-op (idempotency).- Repo/service tests: a project-scoped list returns only that project’s rows
and excludes another project’s copy (explicit exclusion assertion, per the
repository / e2e rules); create stamps
project_id+access_level; missingproject_id→BadRequestError. - Ops task: no test (per
feedback_no_ops_task_tests).