# 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 ? '<key>'` (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).
