> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ionworks.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 2026 07 21 cell spec default model design

# 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_models` — `supabase/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`:

```sql theme={null}
ALTER TABLE cell_specifications
  ADD COLUMN IF NOT EXISTS default_parameterized_model_id uuid
  REFERENCES parameterized_models(id) ON DELETE SET NULL;

CREATE INDEX IF NOT EXISTS idx_cell_specifications_default_pm
  ON cell_specifications(default_parameterized_model_id);
```

* **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:

```python theme={null}
if "default_parameterized_model_id" in data.model_fields_set:
    new_default = data.default_parameterized_model_id
    if new_default is not None:
        pm = await self.parameterized_model_repo.get_parameterized_model_by_id(new_default)
        if pm is None or pm.cell_spec_id != cell_spec_id:
            raise BadRequestError(
                f"Parameterized model '{new_default}' does not belong to cell "
                f"specification '{cell_spec_id}'. The default model must be one "
                f"of this specification's own parameterized models."
            )
    update_payload["default_parameterized_model_id"] = new_default
```

* 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**:

```python theme={null}
# after creating `pm` with pm.cell_spec_id set
spec = await self.cell_spec_repo.get_cell_specification(pm.cell_spec_id)
if spec is not None and spec.default_parameterized_model_id is None:
    await self.cell_spec_repo.update_cell_specification(
        pm.cell_spec_id,
        {"default_parameterized_model_id": pm.id},
    )
```

* 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:

```ts theme={null}
type UpdateCellBody = Partial<Pick<Cell, 'default_parameterized_model_id'>>;

updateCell: builder.mutation<Cell, { cellSpecId: string; body: UpdateCellBody }>({
  query: ({ cellSpecId, body }) => ({
    url: `/cell_specifications/${cellSpecId}`,
    method: 'patch',
    data: body,
  }),
  transformResponse: (cell: any) => mapCell(cell),
  invalidatesTags: (result, error, { cellSpecId }) =>
    invalidateOnSuccess([{ type: 'Cell', id: cellSpecId }])(result, error, undefined),
}),
```

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

| Decision       | Choice                                       | Rationale                                                                                             |
| -------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Which "pm"     | Parameterized model                          | It's literally a "model"; has list/detail UI + `cell_spec_id` FK; planned-measurement has no frontend |
| FK integrity   | Service-layer validation                     | Portable, clear errors; DB composite FK rejected as over-complex                                      |
| Empty default  | Nullable, at most one; auto-default first PM | Matches "default MODEL" mental model; first PM is the obvious default                                 |
| UI surfaces    | Spec's PM tab + PM detail page               | Both are scoped to a spec; global list left untouched                                                 |
| SDK            | Backend + UI only                            | Scope control                                                                                         |
| Confirm dialog | None                                         | Set-as-default is reversible; convention reserves dialogs for irreversible actions                    |
