# Reference-Data Edit/Delete Lifecycle — Design **Date:** 2026-06-05 **Status:** Approved (brainstorming) — ready for implementation planning. **Issues:** #30 (vocabularies/terms/authorities edit+delete), #36 (field-definitions edit+delete). ## Context The admin reference-data surface currently exposes **create + list only** for all four entity kinds: - Vocabularies & terms — `GET/POST /api/admin/vocabularies`, `GET/POST /api/admin/vocabularies/{id}/terms` - Authorities — `GET/POST /api/admin/authorities?kind=` - Field definitions — `GET/POST /api/admin/field-definitions` There is no way to rename a vocabulary, correct a term's labels/URI, fix a misspelled authority, edit a field definition's labels/group/required flag, or delete any of them. In a cataloguing system this is a real operational gap: reference data accrues mistakes that today can only be fixed by direct DB edits. This milestone completes the CRUD lifecycle — backend endpoints **and** frontend UI — for all four kinds. ### The integrity constraint that shapes everything Object flexible-field values live in a **JSONB column on `object`** (migration `0005_object_fields.sql`: `ALTER TABLE object ADD COLUMN fields JSONB NOT NULL DEFAULT '{}'`), keyed by field-definition `key`, with `term`/`authority` references stored as **UUID strings inside that JSON**. There is **no foreign key** from object data to terms/authorities/field-definitions. Consequences: - Deleting a **term, authority, or field-definition** is *not* blocked or cascaded by the database — it would silently leave dangling UUIDs / orphaned keys in objects. Any "is it referenced?" guard must be an explicit JSONB scan we write. - Deleting a **vocabulary** *is* already protected: `term.vocabulary_id` and `field_definition.vocabulary_id` are `REFERENCES vocabulary (id) ON DELETE RESTRICT` (migrations `0002`, `0004`). So a non-empty/bound vocabulary delete fails at the FK level — we catch that and surface a clean 409, rather than invent a check. - `term_label`, `authority_label`, `field_definition_label` are all `ON DELETE CASCADE` from their parent, so deleting a term/authority/field-def cleans up its own labels. ### Decisions settled in brainstorming 1. **Scope:** cohesive — backend **and** frontend edit/delete for all four kinds in one milestone (no backend endpoints shipping without UI). 2. **Delete policy for referenced entities:** **block with 409 + count.** Never silently alter catalogue data. The curator must clear/reassign the referencing object fields first. (Cascade-scrub was rejected as too destructive / no undo.) 3. **Field-definition immutability:** `key`, `data_type`, and binding (`vocabulary_id`/`authority_kind`) are **immutable**; `labels`, `group_key`, `required` are editable. 4. **`required` toggled on:** allowed; governs validation on *future* object writes only — no retroactive scan or block of existing objects. 5. **Vocabulary `key` rename:** allowed (cosmetic — terms and field-definitions bind by vocabulary UUID, not key). 6. **Frontend affordance:** **in-place edit + `AlertDialog` delete** (Option A) — reuse the existing two-pane form panes and the installed AlertDialog; no new shadcn deps. 7. **Storybook:** add co-located stories for components created or meaningfully changed where it makes sense (forms, editable rows, dialogs) — not every trivial change. ## Backend All endpoints are gated by `EditCatalogue` and audited: every mutation runs the insert/update/delete **and** `audit::record(&mut *conn, &NewAuditEvent { actor, action, entity_type, entity_id, .. })` in **one transaction**, mirroring the #21 create-audit pattern (`AuditActor::User(auth.user.id.to_uuid())`). Audit actions: `Updated` / `Deleted`. ### Endpoints **Vocabularies / terms** (`crates/api/src/admin_vocab.rs`) - `PATCH /api/admin/vocabularies/{id}` — rename `key` only. - `DELETE /api/admin/vocabularies/{id}` — allowed only when the vocabulary has no terms and is bound by no field-definition. Enforced by the existing FK `RESTRICT`; the resulting `sqlx` FK error is mapped to **409** with a reason, not a 500. - `PATCH /api/admin/vocabularies/{id}/terms/{term_id}` — edit labels + `external_uri`. - `DELETE /api/admin/vocabularies/{id}/terms/{term_id}` — **409 + count** if any object references the term; otherwise delete (its `term_label` rows cascade). **Authorities** (`crates/api/src/admin_authorities.rs`) - `PATCH /api/admin/authorities/{id}` — edit labels + `external_uri`. `kind` is **immutable** (field bindings filter by kind). - `DELETE /api/admin/authorities/{id}` — **409 + count** if referenced; otherwise delete (`authority_label` cascades). **Field definitions** (`crates/api/src/` field-definition handler) - `PATCH /api/admin/field-definitions/{key}` — edit labels, `group_key`, `required`. The PATCH body exposes **only** those three fields, so `key`/`data_type`/binding are **structurally immutable** (they cannot be sent — no runtime reject needed). Invalid values (empty label, empty `group_key` — both have `CHECK <> ''` constraints) return **422**, consistent with the create path. - `DELETE /api/admin/field-definitions/{key}` — **409 + count** if any object stores that field key; otherwise delete (label cascades). ### Referenced-checks (JSONB scans) - **Term / authority referenced:** count objects whose `fields` JSONB contains the UUID as a value. Use a jsonpath value-match, scoped to the field keys whose definition is `term`/`authority`-typed for that vocabulary/kind, to avoid false positives. Returns a count (and the implementation may also collect a small sample of object numbers for the UI message). - **Field-definition referenced:** count objects where `fields ? ''` (JSONB key-exists operator) — simple and indexable. ### DB layer (`crates/db/src/{vocab,authority,fields}.rs`) New functions, each taking `actor: AuditActor` and a `&mut PgConnection` and writing its audit entry atomically (mirroring the existing create functions): - `vocab::rename_vocabulary`, `vocab::delete_vocabulary`, `vocab::update_term`, `vocab::delete_term`, `vocab::count_objects_referencing_term` - `authority::update_authority`, `authority::delete_authority`, `authority::count_objects_referencing_authority` - `fields::update_field_definition`, `fields::delete_field_definition`, `fields::count_objects_using_field` `delete_vocabulary` maps the FK-restrict error to a typed "in use" error so the handler can return 409. The three `count_*` helpers are read-only (no audit, no actor). ### API error shape Reuse the existing typed-error conventions: - **409 (referenced / in-use):** JSON body with the blocking count (e.g. `{ "code": "in_use", "count": N }`) so the UI can render "used by N objects". - **422 (invalid value):** the existing field-error shape (`{ field, code }`, e.g. empty label / empty `group_key`), consistent with `set_fields`' `FieldErrorView` and the create path. (Immutability is enforced structurally by the PATCH DTO, not by a runtime 422.) - **404:** entity not found. OpenAPI is regenerated so `web/src/api/schema.d.ts` picks up the new endpoints and DTOs. ## Frontend **Affordance: in-place edit + `AlertDialog` delete**, per screen layout. Deletes everywhere use the installed `AlertDialog`, mirroring the existing `DeleteObjectDialog`. ### Per-screen **Fields** (`web/src/fields/`, two-pane `/fields`) - Selecting a row in the left `FieldList` turns the right pane (`FieldForm`) into an **edit form**: `labels` / `group_key` / `required` editable; `key` / `data_type` / binding shown **disabled**. A "New field" button resets the pane to create mode. - Each list row gets a delete affordance → `AlertDialog` confirm. **Vocabularies** (`web/src/vocab/`, two-pane `/vocabularies/:id`) - Left `VocabularyList` rows: rename-`key` (inline edit) + delete. - Right-pane (`VocabularyTerms`) term rows: edit (labels/URI) + delete. **Authorities** (`web/src/authorities/`, single-pane `/authorities/:kind`) - Each authority row: inline-expand edit (labels/URI) + delete. ### Hooks (`web/src/api/queries.ts`) Add, mirroring `useUpdateObject`/`useDeleteObject` and invalidating the correct list query keys: `useRenameVocabulary`, `useDeleteVocabulary`, `useUpdateTerm`, `useDeleteTerm`, `useUpdateAuthority`, `useDeleteAuthority`, `useUpdateFieldDefinition`, `useDeleteFieldDefinition`. The **409 (referenced)** and **422 (immutable)** responses parse into the existing `HttpError`/`FieldRejection` style. ### Delete-blocked UX On a 409 the `AlertDialog` **stays open** and shows the blocking reason — `t("actions.inUse", { count })` → e.g. *"Used by 7 objects — clear those fields first"* — instead of closing. ### i18n (`web/src/i18n/{en,sv}.json`) New action keys under `vocab.*` / `authorities.*` / `fields.*` plus a shared `actions.*` namespace where it makes sense: `edit`, `delete`, `rename`, `save`, `cancel`, `inUse` (count-interpolated). **en/sv parity** required. ### Storybook Add co-located `*.stories.tsx` for the components that gain meaningful states: the field-definition edit form, the editable term/authority row, and the delete-confirm dialog wrapper. (Skip trivially-changed components.) ## Testing **Backend** (existing infra: compose Postgres on host 5442, Meili on 7700; `#[sqlx::test]` provisions its own DB; mirror `crates/api/tests/admin_catalog.rs`): - **db:** update/delete per entity; each `count_objects_referencing_*` returns the right count; delete blocked when referenced; `delete_vocabulary` FK-restrict → typed in-use error; an audit row (`Updated`/`Deleted`, correct actor) per mutation. - **api:** each `PATCH`/`DELETE` — `EditCatalogue` required (401/403 without), happy path (200/204), **409 + count** when referenced, **422** on an invalid value (empty `group_key`/label), **404** when missing. (Immutables are absent from the PATCH DTO, so there is no "immutable changed" path to test.) - OpenAPI regenerated. **Frontend** (Vitest + RTL + MSW, `onUnhandledRequest: "error"`): - Mutation hooks invalidate the correct keys; a 409 parses into the typed error. - Per screen: edit form populates and saves; delete confirms via `AlertDialog`; a 409 keeps the dialog open showing "used by N"; field-def edit shows key/type/binding disabled. - en/sv key-parity check (existing test). - Storybook stories for the meaningfully-changed components run green under the addon-vitest project. ## Acceptance criteria 1. Update + delete endpoints for vocabulary (rename), term, authority, field-definition — all `EditCatalogue`, all audited. 2. Referenced term/authority/field-def delete → **409 + count**; vocabulary delete → **409** when it has terms or is bound. 3. Field-def `key`/`data_type`/binding immutable (absent from the PATCH DTO — cannot be changed); `labels`/`group`/`required` editable; `required` not retroactively enforced. 4. In-place edit + `AlertDialog` delete on all three screens; a blocked delete shows "used by N" without closing. 5. Storybook stories added for the meaningfully-changed components. 6. en/sv parity; no `any`/`eslint-disable`/`@ts-ignore`; codename ban; bundle ≤150 KB gz; cargo + web typecheck/lint/test/build green; OpenAPI regenerated. ## Out of scope / follow-ups - A "find/replace a reference across objects" bulk-reassign tool (would let a curator clear references blocking a delete in one action) — file if the 409 friction is felt. - Surfacing the referencing objects as clickable links in the 409 message (v1 shows a count + optional sample, not a live list). - Audit-entry coalescing for multi-step edits (#13, separate).