Cell specification “default model” design
Summary
Give a cell specification an optional pointer to one of its own parameterized models (PMs), the “default model”. The pointer is:- Settable from the API via the existing cell-spec PATCH endpoint.
- Settable and highlighted in the UI in two places — the cell spec detail page’s “Parameterized models” tab, and the parameterized-model detail page.
- Auto-populated when the first PM is created for a spec.
Terminology
“PM” throughout this document means parameterized model (tableparameterized_models), not “planned measurement”. A parameterized model is a
model + a set of fitted parameters, already scoped to a cell spec via
parameterized_models.cell_spec_id. The new “default model” pointer is the
inverse: it selects one of a spec’s PMs as the canonical/default one.
Current state (references)
- Cell spec response + update models:
backend/src/pydantic_models/cells.py(CellSpecification:453,UpdateCellSpecificationNested:574). - Cell spec update service:
backend/src/services/cell_specification_service.py(update_cell_spec_nested:504) — buildsupdate_payloadfrom provided fields, then calls the repo. - Cell spec update route:
PATCH /cell_specifications/{cell_spec_id}—backend/src/routes/cell_specifications.py:187. - Cell spec repo:
backend/src/repositories/cell_specifications.py(get_cell_specification:41,update_cell_specification:67). - PM table:
parameterized_models—supabase/migrations/20260428000000_baseline.sql:2394, PKid, columncell_spec_id:2397, unique(cell_spec_id, name). - PM repo:
backend/src/repositories/parameterized_models.py(create_parameterized_model:243,list_by_cell_specification:78,get_parameterized_model_by_id:51single-fetch). - PM-creation call sites (both pass optional
cell_spec_id):backend/src/services/ecm.py:1357,backend/src/services/cycler_protocol_service.py:227. - Frontend cell-spec RTK Query:
frontend/src/redux/api/cells-api.ts(getCell:120; no update mutation exists yet). - Cell type:
frontend/src/redux/types/cell.ts. - Spec’s PM tab:
frontend/src/sections/data-management/components/spec-parameterized-models-tab.tsx. - PM detail page:
frontend/src/sections/parameterized-model/containers/parameterized-model-details-container.tsx. - PM RTK Query (reference for tags/mutations):
frontend/src/redux/api/parameterized-models-api.ts.
Design
1. Database (migration, DDL only)
New migrationsupabase/migrations/<ts>_add_default_parameterized_model_to_cell_specs.sql:
- Additive + idempotent per
.claude/rules/supabase-migrations.md. ON DELETE SET NULL: when the referenced PM is deleted, the pointer clears automatically — no application code needed for the “default PM deleted” case.- No composite FK. The “PM must belong to this spec” invariant is enforced
in the service (chosen for portability and clear error messages). A real
composite FK
(cell_spec_id, default_parameterized_model_id)→parameterized_models(cell_spec_id, id)was considered and rejected: it would need a composite unique index onparameterized_modelsand a more complex migration for a rule that a service check covers well. - No data backfill in the migration. Auto-defaulting of existing specs is out of scope (see “Out of scope”); the auto-default rule applies to newly created PMs going forward.
2. Backend
Pydantic (backend/src/pydantic_models/cells.py)
CellSpecification(response): adddefault_parameterized_model_id: str | None = Field(None, ...). It flows throughCellSpecificationWithComponentsautomatically (subclass).UpdateCellSpecificationNested: adddefault_parameterized_model_id: str | None = Field(None, ...).
update_cell_spec_nested currently adds a field to update_payload only when
it is not None, so a None value can never be written. To support clearing
the default, the service must distinguish “field omitted” from “field explicitly
set to null”. Use Pydantic v2 data.model_fields_set — the set of fields the
caller actually provided — rather than a None check, for this one field:
- Requires the service to hold a
parameterized_model_repo(inject alongside the existing repos inCellSpecificationService.__init__). - Raises
BadRequestError(domain exception, per the fastapi-backend rule) with a consumer-facing message naming the bad value and the expected relationship. - The route (
cell_specifications.py:187) is already thin and needs no change — it forwardsUpdateCellSpecificationNestedto the service and lets exceptions propagate.
cell_spec_id, set the spec’s
default_parameterized_model_id to the new PM only if the spec has no default
yet:
- Guard
is Noneensures it only fills an empty default; it never overrides an existing default. Idempotent-safe: re-running with a default already set is a no-op. - Applies at every PM-creation site that supplies a
cell_spec_id. There are three such sites:backend/src/services/ecm.py(ECM-fit),backend/src/services/cycler_protocol_service.py(cycler protocol), and the generic route handlerbackend/src/routes/parameterized_models.py(POST /cells/{cell_spec_id}/parameterized_models, used by the standard create-PM UI/API). All three callcreate_parameterized_modelwith acell_spec_id. A shared helper (set_default_pm_if_unset) is called from all three sites after creation so any first PM for a spec — however it was created — becomes the default. A plan that patches only some sites is incomplete. (The two service sites were identified during the initial exploration; the generic route was found during E2E implementation and wired in the same way.) - Placement is the service layer (so it can reach the cell-spec repo), not the low-level repo, to keep the repo free of cross-table writes.
3. Frontend
frontend/src/redux/api/cells-api.ts
- Add
default_parameterized_model_idtoKNOWN_CELL_KEYSand map it inmapCell(default_parameterized_model_id: cell.default_parameterized_model_id ?? null). - Add an
updateCellPATCH mutation (none exists today):
useUpdateCellMutation. Uses axios.patch semantics (method
'patch') per the HTTP-methods rule.
frontend/src/redux/types/cell.ts
- Add
default_parameterized_model_id?: string | nulltoCell.
spec-parameterized-models-tab.tsx)
- Fetch the spec via
useGetCellQuery(specId)to readdefault_parameterized_model_id. - Highlight: in the
namecolumnrenderCell, whenparams.row.id === defaultId, render a “Default” chip next to the name (MUIChip,size="small",color="primary", theme tokens — dark-mode safe). - Set as default: add an
actionscolumn (type: 'actions',showInMenu,CustomGridActionsCellItem) with a “Set as default” item that callsupdateCell({ cellSpecId: specId, body: { default_parameterized_model_id: row.id } }). The item is disabled on the row that is already the default. - No confirmation dialog (reversible action, per the frontend conventions
rule — confirm only irreversible actions). On success:
toast.success('Set "<name>" as the default model'); on errortoast.error(extractApiError(error, 'Failed to set default model').message). RTK Query tag invalidation refreshes both the chip position and the PM detail page. - Column layout follows
.claude/rules/frontend-data-tables.md(actions column pinned right viaStandardDataGriddefaults).
parameterized-model-details-container.tsx)
- Fetch the owning spec via
useGetCellQuery(parameterizedModel.cell_spec_id). - Show a “Default for this cell spec” badge (MUI
Chip,color="primary") in the header whenpm.id === cellSpec.default_parameterized_model_id. - Show a “Set as default” button (
variant="outlined") that is hidden or disabled when this PM is already the default; on click, sameupdateCellcall, same toast + no-dialog behavior.
4. Error handling
- Invalid default (PM not found or belongs to another spec) →
BadRequestError(400,error_code: BAD_REQUEST) with an actionable message. - Frontend reads all errors through
extractApiErrorand surfaces viatoast.errorper the fastapi-backend + frontend rules.
5. Testing
Backend (just test-backend) — service-level tests for
update_cell_spec_nested and the PM-creation auto-default:
- Setting a PM that belongs to the spec persists
default_parameterized_model_id. - Setting a PM that belongs to a different spec raises
BadRequestError. - Setting a non-existent PM id raises
BadRequestError. - Explicit
nullclears the default (verifies themodel_fields_setpath). - Omitting the field leaves the existing default unchanged.
- Creating the first PM for a spec sets the spec’s default to that PM.
- Creating a second PM does not change an already-set default.
- (DB-level
ON DELETE SET NULLis a Postgres guarantee; covered by the search integration suite if a spec+PM fixture already exists, otherwise not unit-tested.)
just test-frontend-unit)
cells-apiupdateCellmutation test withmockAxios(assertspatchto/cell_specifications/{id}with the right body; assertsCelltag invalidation).
just test-frontend, targeted) — per
.claude/rules/playwright-e2e-tests.md:
- Seed a spec with two PMs (timestamp-suffixed names). Open the spec’s PM tab.
- Assert exactly one row shows the “Default” chip (the auto-defaulted first PM).
- Use the 3-dot menu “Set as default” on the second PM; assert the chip moves to the second row and is absent from the first (exclusion assertion, not just presence).
- Assert the “Set as default” item is disabled on the current-default row.
Out of scope
- Global standalone Parameterized Models list page (no changes).
- Python SDK client (
packages/ionworks-api) — no method this round. - DB composite foreign key enforcement.
- Backfilling a default for cell specs that already have PMs (auto-default applies to newly created PMs going forward only).
- Confirmation dialog on replace (explicitly decided against).