Skip to main content

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.
The referenced PM must belong to the same cell spec; this is enforced in the service layer, not by a database composite foreign key.

Terminology

“PM” throughout this document means parameterized model (table parameterized_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) — builds update_payload from 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_modelssupabase/migrations/20260428000000_baseline.sql:2394, PK id, column cell_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:51 single-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 migration supabase/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 on parameterized_models and 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): add default_parameterized_model_id: str | None = Field(None, ...). It flows through CellSpecificationWithComponents automatically (subclass).
  • UpdateCellSpecificationNested: add default_parameterized_model_id: str | None = Field(None, ...).
Distinguishing “omit” from “set to null”. 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 in CellSpecificationService.__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 forwards UpdateCellSpecificationNested to the service and lets exceptions propagate.
Auto-default the first PM. After a PM is created with a 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 None ensures 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 handler backend/src/routes/parameterized_models.py (POST /cells/{cell_spec_id}/parameterized_models, used by the standard create-PM UI/API). All three call create_parameterized_model with a cell_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_id to KNOWN_CELL_KEYS and map it in mapCell (default_parameterized_model_id: cell.default_parameterized_model_id ?? null).
  • Add an updateCell PATCH mutation (none exists today):
Pin the mutation body to a concrete, minimal type — do not broaden the mutation surface beyond this round’s needs:
Export useUpdateCellMutation. Uses axios.patch semantics (method 'patch') per the HTTP-methods rule. frontend/src/redux/types/cell.ts
  • Add default_parameterized_model_id?: string | null to Cell.
Spec’s PM tab (spec-parameterized-models-tab.tsx)
  • Fetch the spec via useGetCellQuery(specId) to read default_parameterized_model_id.
  • Highlight: in the name column renderCell, when params.row.id === defaultId, render a “Default” chip next to the name (MUI Chip, size="small", color="primary", theme tokens — dark-mode safe).
  • Set as default: add an actions column (type: 'actions', showInMenu, CustomGridActionsCellItem) with a “Set as default” item that calls updateCell({ 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 error toast.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 via StandardDataGrid defaults).
PM detail page (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 when pm.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, same updateCell call, 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 extractApiError and surfaces via toast.error per 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 null clears the default (verifies the model_fields_set path).
  • 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 NULL is a Postgres guarantee; covered by the search integration suite if a spec+PM fixture already exists, otherwise not unit-tested.)
Frontend (just test-frontend-unit)
  • cells-api updateCell mutation test with mockAxios (asserts patch to /cell_specifications/{id} with the right body; asserts Cell tag invalidation).
E2E (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).

Decisions log