# Reference-Data Edit/Delete Lifecycle Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add update + delete across the admin reference-data surface — vocabularies (rename), terms, authorities, and field definitions — with backend endpoints (audited, referenced-entity-safe) and in-place frontend edit/delete UI.

**Architecture:** Each backend task adds db-layer functions (mutation + atomic `audit::record`, plus a read-only referenced-count) and the matching axum handlers. Deletes that would orphan object data are blocked with **409 + count**; the count comes from a JSONB scan of `object.fields` (object flexible-field values are stored as a JSONB blob with no FK to reference data). The frontend mirrors the existing `useUpdateObject`/`useDeleteObject` hooks and the `DeleteObjectDialog`/`AlertDialog` pattern, editing in place (no modal dialogs, no new shadcn components).

**Tech Stack:** Rust (axum 0.8, sqlx 0.8 + Postgres, utoipa 5, thiserror), React 19 + TypeScript + pnpm, TanStack Query v5, react-i18next, shadcn/ui (Base UI "base-nova"), Vitest + RTL + MSW, Storybook 10.

**Conventions:** `cargo +nightly fmt`; `cargo clippy --workspace --all-targets -- -D warnings`; no `any`/`eslint-disable`/`@ts-ignore`; en/sv i18n parity; storage always UTC; **codename ban** (never write "biggus"/"dickus"). Spec: `docs/superpowers/specs/2026-06-05-reference-data-edit-delete-design.md`.

**Test infra:** compose Postgres on host **5442**, Meili on **7700**. Backend tests:
```
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev \
MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test ...
```
`#[sqlx::test(migrations = "../db/migrations")]` provisions its own temp DB. Run `docker compose up -d` first and confirm `pg_isready`. Web tests: `cd web && pnpm test` (jsdom + storybook projects), `pnpm typecheck`, `pnpm lint`, `pnpm build`.

---

## File Structure

**Backend**
- `crates/db/src/lib.rs` — add `pub enum DeleteOutcome { Deleted, InUse { count: i64 }, NotFound }` (shared by all delete fns).
- `crates/db/src/vocab.rs` — add `update_term`, `delete_term`, `count_objects_referencing_term`, `rename_vocabulary`, `delete_vocabulary`.
- `crates/db/src/authority.rs` — add `update_authority`, `delete_authority`, `count_objects_referencing_authority`.
- `crates/db/src/fields.rs` — add audit wiring + `update_field_definition`, `delete_field_definition`, `count_objects_using_field`.
- `crates/api/src/admin_vocab.rs` — PATCH/DELETE term + PATCH/DELETE vocabulary handlers, DTOs, routes; shared `InUseView`.
- `crates/api/src/admin_authorities.rs` — PATCH/DELETE authority handler, DTO, routes.
- `crates/api/src/admin_objects.rs` — PATCH/DELETE field-definition handlers, DTOs, routes; define `pub(crate) struct InUseView`.
- `crates/api/src/openapi.rs` — register all new paths + schemas.
- Tests: `crates/db/tests/vocab.rs`, `crates/db/tests/authority.rs`, `crates/db/tests/fields.rs` (new), `crates/api/tests/admin_catalog.rs`.

**Frontend**
- `web/src/api/schema.d.ts` — regenerated (Task 5).
- `web/src/api/queries.ts` — 8 mutation hooks + `InUseError` class.
- `web/src/i18n/en.json`, `web/src/i18n/sv.json` — action/in-use keys.
- `web/src/components/delete-confirm-dialog.tsx` (new) + `.stories.tsx` — generic delete confirm with in-use handling.
- `web/src/fields/field-form.tsx`, `field-list.tsx`, `fields-page.tsx` (+ stories) — edit mode + delete.
- `web/src/vocab/vocabulary-list.tsx`, `vocabulary-terms.tsx` + new `term-row.tsx` (+ stories) — rename + term edit/delete.
- `web/src/authorities/authorities-page.tsx` + new `authority-row.tsx` (+ stories) — edit/delete.

**Shared db delete contract (defined in Task 1, used by all):**
```rust
/// Result of a delete that may be blocked by references from catalogue objects.
pub enum DeleteOutcome {
    Deleted,
    InUse { count: i64 },
    NotFound,
}
```

---

## Task 1: Term edit/delete (db + api)

**Files:**
- Modify: `crates/db/src/lib.rs` (add `DeleteOutcome`)
- Modify: `crates/db/src/vocab.rs`
- Modify: `crates/api/src/admin_vocab.rs`, `crates/api/src/openapi.rs`
- Test: `crates/db/tests/vocab.rs`, `crates/api/tests/admin_catalog.rs`

- [ ] **Step 1: Add the shared `DeleteOutcome` enum.** In `crates/db/src/lib.rs`, add near the top (after the existing `pub mod`/`pub use` lines):

```rust
/// Result of a delete that catalogue-object references may block.
#[derive(Debug, PartialEq, Eq)]
pub enum DeleteOutcome {
    /// The row was deleted.
    Deleted,
    /// Refused: `count` catalogue objects still reference it.
    InUse { count: i64 },
    /// The row did not exist.
    NotFound,
}
```

- [ ] **Step 2: Write failing db tests for term update/delete.** Append to `crates/db/tests/vocab.rs` (mirror the existing tests' setup — they build `Db`, create a vocabulary + term via `vocab::create_vocabulary`/`vocab::add_term` inside a tx with `AuditActor::System`). Reference how `set_object_fields` is used in `crates/db/tests/object_fields.rs` to attach a term value to an object.

```rust
#[sqlx::test(migrations = "../db/migrations")]
async fn update_term_changes_labels_and_uri(pool: PgPool) {
    use db::DeleteOutcome;
    let db = Db::from_pool(pool);
    let mut tx = db.pool().begin().await.unwrap();
    let vocab = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
        .await
        .unwrap();
    let term_id = vocab::add_term(
        &mut tx,
        AuditActor::System,
        &NewTerm {
            vocabulary_id: vocab.id,
            external_uri: None,
            labels: vec![LocalizedLabel { lang: "sv".into(), label: "Trä".into() }],
        },
    )
    .await
    .unwrap();

    let existed = vocab::update_term(
        &mut tx,
        AuditActor::System,
        term_id,
        Some("https://example.org/wood"),
        &[LocalizedLabel { lang: "sv".into(), label: "Träslag".into() }],
    )
    .await
    .unwrap();
    assert!(existed);
    tx.commit().await.unwrap();

    let term = vocab::term_by_id(db.pool(), term_id).await.unwrap().unwrap();
    assert_eq!(term.external_uri.as_deref(), Some("https://example.org/wood"));
    assert_eq!(term.labels.len(), 1);
    assert_eq!(term.labels[0].label, "Träslag");
    let _ = DeleteOutcome::Deleted; // import smoke
}

#[sqlx::test(migrations = "../db/migrations")]
async fn delete_term_blocks_when_referenced_then_succeeds(pool: PgPool) {
    use db::{DeleteOutcome, catalog, fields};
    let db = Db::from_pool(pool);
    let mut tx = db.pool().begin().await.unwrap();
    let vocab = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
        .await
        .unwrap();
    let term_id = vocab::add_term(
        &mut tx,
        AuditActor::System,
        &NewTerm { vocabulary_id: vocab.id, external_uri: None,
                   labels: vec![LocalizedLabel { lang: "sv".into(), label: "Trä".into() }] },
    ).await.unwrap();
    // A field definition of type `term` + an object using this term.
    fields::create_field_definition(&mut tx, &NewFieldDefinition {
        key: "material".into(),
        field_type: domain::FieldType::Term { vocabulary_id: vocab.id },
        required: false, group_key: None,
        labels: vec![LocalizedLabel { lang: "sv".into(), label: "Material".into() }],
    }).await.unwrap();
    let obj = catalog::create_object(&mut tx, AuditActor::System, &sample_object_input()).await.unwrap();
    let mut map = serde_json::Map::new();
    map.insert("material".into(), serde_json::Value::String(term_id.to_string()));
    catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map).await.unwrap();

    // Referenced → blocked with a count of 1.
    let blocked = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id).await.unwrap();
    assert_eq!(blocked, DeleteOutcome::InUse { count: 1 });

    // Clear the reference, then delete succeeds.
    catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new()).await.unwrap();
    let ok = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id).await.unwrap();
    assert_eq!(ok, DeleteOutcome::Deleted);
    assert!(vocab::term_by_id(&mut *tx, term_id).await.unwrap().is_none());

    // Deleting again → NotFound.
    let gone = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id).await.unwrap();
    assert_eq!(gone, DeleteOutcome::NotFound);
}
```

Add a small helper at the bottom of the test file if one does not already exist (copy field names from `crates/db/tests/object_fields.rs`'s object setup):
```rust
fn sample_object_input() -> domain::ObjectInput {
    domain::ObjectInput {
        object_number: "X.1".into(),
        object_name: "Test".into(),
        number_of_objects: 1,
        brief_description: None,
        current_location: None,
        current_owner: None,
        recorder: None,
        recording_date: None,
        visibility: domain::Visibility::Draft,
    }
}
```
Ensure the test file's `use` includes `db::{vocab}`, `domain::{AuditActor, LocalizedLabel, NewTerm, NewFieldDefinition}`, `sqlx::PgPool`, and `db::Db`.

- [ ] **Step 3: Run the tests to confirm they fail.**
```
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test vocab
```
Expected: FAIL — `update_term`/`delete_term` not found.

- [ ] **Step 4: Implement the db functions.** In `crates/db/src/vocab.rs`, add to the `use` line `DeleteOutcome` is in the `db` crate root, so reference it as `crate::DeleteOutcome`. Add:

```rust
/// Update a term's `external_uri` and labels (full replace), recording an `updated`
/// audit entry. Returns `false` if no such term. Pass a transaction connection.
pub async fn update_term(
    conn: &mut sqlx::PgConnection,
    actor: AuditActor,
    term_id: TermId,
    external_uri: Option<&str>,
    labels: &[LocalizedLabel],
) -> Result<bool, sqlx::Error> {
    let updated = sqlx::query("UPDATE term SET external_uri = $2 WHERE id = $1")
        .bind(term_id.to_uuid())
        .bind(external_uri)
        .execute(&mut *conn)
        .await?
        .rows_affected();

    if updated == 0 {
        return Ok(false);
    }

    sqlx::query("DELETE FROM term_label WHERE term_id = $1")
        .bind(term_id.to_uuid())
        .execute(&mut *conn)
        .await?;

    for label in labels {
        sqlx::query("INSERT INTO term_label (term_id, lang, label) VALUES ($1, $2, $3)")
            .bind(term_id.to_uuid())
            .bind(&label.lang)
            .bind(&label.label)
            .execute(&mut *conn)
            .await?;
    }

    audit::record(
        &mut *conn,
        &NewAuditEvent {
            actor,
            action: AuditAction::Updated,
            entity_type: TERM_ENTITY_TYPE.to_owned(),
            entity_id: term_id.to_uuid(),
            changes: Vec::new(),
        },
    )
    .await?;

    Ok(true)
}

/// Count catalogue objects that reference `term_id` through a `term`-typed field.
pub async fn count_objects_referencing_term<'e, E>(
    executor: E,
    term_id: TermId,
) -> Result<i64, sqlx::Error>
where
    E: sqlx::PgExecutor<'e>,
{
    sqlx::query_scalar(
        "SELECT count(*) FROM object o WHERE EXISTS ( \
           SELECT 1 FROM field_definition fd \
           WHERE fd.data_type = 'term' AND o.fields ->> fd.key = $1 )",
    )
    .bind(term_id.to_string())
    .fetch_one(executor)
    .await
}

/// Delete a term (its labels cascade) unless catalogue objects reference it, recording a
/// `deleted` audit entry. Pass a transaction connection.
pub async fn delete_term(
    conn: &mut sqlx::PgConnection,
    actor: AuditActor,
    vocabulary_id: VocabularyId,
    term_id: TermId,
) -> Result<crate::DeleteOutcome, sqlx::Error> {
    let exists = sqlx::query_scalar::<_, i32>(
        "SELECT 1 FROM term WHERE id = $1 AND vocabulary_id = $2",
    )
    .bind(term_id.to_uuid())
    .bind(vocabulary_id.to_uuid())
    .fetch_optional(&mut *conn)
    .await?;

    if exists.is_none() {
        return Ok(crate::DeleteOutcome::NotFound);
    }

    let count = count_objects_referencing_term(&mut *conn, term_id).await?;
    if count > 0 {
        return Ok(crate::DeleteOutcome::InUse { count });
    }

    sqlx::query("DELETE FROM term WHERE id = $1")
        .bind(term_id.to_uuid())
        .execute(&mut *conn)
        .await?;

    audit::record(
        &mut *conn,
        &NewAuditEvent {
            actor,
            action: AuditAction::Deleted,
            entity_type: TERM_ENTITY_TYPE.to_owned(),
            entity_id: term_id.to_uuid(),
            changes: Vec::new(),
        },
    )
    .await?;

    Ok(crate::DeleteOutcome::Deleted)
}
```

- [ ] **Step 5: Run the db tests to confirm they pass.**
```
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test vocab
```
Expected: PASS.

- [ ] **Step 6: Write failing api tests** in `crates/api/tests/admin_catalog.rs` (mirror `create_list_vocabulary_and_terms` for setup: `seed_user(Role::Editor)`, `build_app(state(pool))`, `login(...)`, then `oneshot` requests with the session cookie). Add a small PATCH/DELETE helper if none exists:

```rust
async fn send(app: &axum::Router, cookie: &str, method: &str, uri: &str, body: Option<&str>) -> axum::http::Response<Body> {
    let mut req = Request::builder().method(method).uri(uri).header(header::COOKIE, cookie);
    if let Some(b) = body {
        req = req.header(header::CONTENT_TYPE, "application/json");
        app.clone().oneshot(req.body(Body::from(b.to_owned())).unwrap()).await.unwrap()
    } else {
        app.clone().oneshot(req.body(Body::empty()).unwrap()).await.unwrap()
    }
}
```

```rust
#[sqlx::test(migrations = "../db/migrations")]
async fn edit_and_delete_term(pool: PgPool) {
    seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
    let app = build_app(state(pool));
    let cookie = login(&app, "ed@example.com", "pw-editor-123").await;

    // create a vocabulary + term
    let v = send(&app, &cookie, "POST", "/api/admin/vocabularies",
                 Some(r#"{"key":"material"}"#)).await;
    let vid: serde_json::Value = serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap();
    let vid = vid["id"].as_str().unwrap().to_owned();
    let t = send(&app, &cookie, "POST", &format!("/api/admin/vocabularies/{vid}/terms"),
                 Some(r#"{"external_uri":null,"labels":[{"lang":"sv","label":"Trä"}]}"#)).await;
    let tid: serde_json::Value = serde_json::from_slice(&t.into_body().collect().await.unwrap().to_bytes()).unwrap();
    let tid = tid["id"].as_str().unwrap().to_owned();

    // PATCH the term
    let patched = send(&app, &cookie, "PATCH",
        &format!("/api/admin/vocabularies/{vid}/terms/{tid}"),
        Some(r#"{"external_uri":"https://x","labels":[{"lang":"sv","label":"Träslag"}]}"#)).await;
    assert_eq!(patched.status(), StatusCode::NO_CONTENT);

    // DELETE the (unreferenced) term
    let deleted = send(&app, &cookie, "DELETE",
        &format!("/api/admin/vocabularies/{vid}/terms/{tid}"), None).await;
    assert_eq!(deleted.status(), StatusCode::NO_CONTENT);

    // DELETE again → 404
    let again = send(&app, &cookie, "DELETE",
        &format!("/api/admin/vocabularies/{vid}/terms/{tid}"), None).await;
    assert_eq!(again.status(), StatusCode::NOT_FOUND);
}

#[sqlx::test(migrations = "../db/migrations")]
async fn term_edit_delete_requires_auth(pool: PgPool) {
    let app = build_app(state(pool));
    let r = app.clone().oneshot(
        Request::builder().method("DELETE")
            .uri("/api/admin/vocabularies/00000000-0000-0000-0000-000000000000/terms/00000000-0000-0000-0000-000000000000")
            .body(Body::empty()).unwrap()).await.unwrap();
    assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
}
```

- [ ] **Step 7: Run to confirm failure.**
```
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api --test admin_catalog edit_and_delete_term
```
Expected: FAIL (routes 404 / method not allowed).

- [ ] **Step 8: Implement the api handlers, DTOs, and routes.** In `crates/api/src/admin_vocab.rs`:
  - Add imports: `axum::response::{IntoResponse, Response}`, `axum::routing` already imported via `get`. Add `domain::TermId`. Import the shared in-use view: `use crate::admin_objects::InUseView;` (defined in Task 4 / or define here if Task 4 not yet done — see note).
  - Add DTOs:
```rust
#[derive(Deserialize, ToSchema)]
pub(crate) struct UpdateTermRequest {
    pub external_uri: Option<String>,
    pub labels: Vec<LabelInput>,
}
```
  - Add handlers:
```rust
#[utoipa::path(
    patch, path = "/api/admin/vocabularies/{id}/terms/{term_id}",
    request_body = UpdateTermRequest,
    params(("id" = String, Path, description = "Vocabulary id (UUID)"),
           ("term_id" = String, Path, description = "Term id (UUID)")),
    responses((status = 204), (status = 401), (status = 403), (status = 404))
)]
pub(crate) async fn update_term(
    auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    Path((_id, term_id)): Path<(String, String)>,
    Json(req): Json<UpdateTermRequest>,
) -> Result<StatusCode, StatusCode> {
    let term_id = term_id.parse::<TermId>().map_err(|_| StatusCode::NOT_FOUND)?;
    let labels: Vec<LocalizedLabel> = req.labels.into_iter()
        .map(|l| LocalizedLabel { lang: l.lang, label: l.label }).collect();

    let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let existed = db::vocab::update_term(
        &mut tx, AuditActor::User(auth.user.id.to_uuid()),
        term_id, req.external_uri.as_deref(), &labels,
    ).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    if existed { Ok(StatusCode::NO_CONTENT) } else { Err(StatusCode::NOT_FOUND) }
}

#[utoipa::path(
    delete, path = "/api/admin/vocabularies/{id}/terms/{term_id}",
    params(("id" = String, Path, description = "Vocabulary id (UUID)"),
           ("term_id" = String, Path, description = "Term id (UUID)")),
    responses((status = 204), (status = 401), (status = 403), (status = 404),
              (status = 409, body = InUseView, description = "Referenced by catalogue objects"))
)]
pub(crate) async fn delete_term(
    auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    Path((id, term_id)): Path<(String, String)>,
) -> Response {
    let (Ok(vocab_id), Ok(term_id)) = (id.parse::<VocabularyId>(), term_id.parse::<TermId>()) else {
        return StatusCode::NOT_FOUND.into_response();
    };

    let Ok(mut tx) = state.db.pool().begin().await else {
        return StatusCode::INTERNAL_SERVER_ERROR.into_response();
    };
    let outcome = db::vocab::delete_term(&mut tx, AuditActor::User(auth.user.id.to_uuid()), vocab_id, term_id).await;

    match outcome {
        Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
            Ok(()) => StatusCode::NO_CONTENT.into_response(),
            Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
        },
        Ok(db::DeleteOutcome::InUse { count }) => (StatusCode::CONFLICT, Json(InUseView { count })).into_response(),
        Ok(db::DeleteOutcome::NotFound) => StatusCode::NOT_FOUND.into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}
```
  - **Note on `InUseView`:** it is owned by Task 4 (`admin_objects.rs`). To keep tasks independently compilable, define it here in `admin_vocab.rs` instead and have Task 4 import it from here:
```rust
/// 409 body: how many catalogue objects still reference the entity.
#[derive(Serialize, ToSchema)]
pub(crate) struct InUseView {
    pub count: i64,
}
```
Then drop the `use crate::admin_objects::InUseView;` import above and reference the local `InUseView`. (Adjust the File Structure note accordingly: `InUseView` lives in `admin_vocab.rs`.)
  - Extend `routes()`:
```rust
.route(
    "/api/admin/vocabularies/{id}/terms/{term_id}",
    axum::routing::patch(update_term).delete(delete_term),
)
```

- [ ] **Step 9: Register in OpenAPI.** In `crates/api/src/openapi.rs`, add to `paths(...)`: `admin_vocab::update_term,` and `admin_vocab::delete_term,`. Add to `components(schemas(...))`: `admin_vocab::UpdateTermRequest,` and `admin_vocab::InUseView,`.

- [ ] **Step 10: Run api tests + fmt + clippy.**
```
cargo +nightly fmt
cargo clippy -p api -p db --all-targets -- -D warnings
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api -p db
```
Expected: all PASS, clippy clean.

- [ ] **Step 11: Commit.**
```bash
git add crates/db crates/api
git commit -m "feat: edit/delete terms — audited, blocked when referenced (#30)"
```

---

## Task 2: Vocabulary rename/delete (db + api)

**Files:**
- Modify: `crates/db/src/vocab.rs`, `crates/api/src/admin_vocab.rs`, `crates/api/src/openapi.rs`
- Test: `crates/db/tests/vocab.rs`, `crates/api/tests/admin_catalog.rs`

- [ ] **Step 1: Write failing db tests** in `crates/db/tests/vocab.rs`:
```rust
#[sqlx::test(migrations = "../db/migrations")]
async fn rename_vocabulary_changes_key(pool: PgPool) {
    let db = Db::from_pool(pool);
    let mut tx = db.pool().begin().await.unwrap();
    let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "old").await.unwrap();
    let existed = vocab::rename_vocabulary(&mut tx, AuditActor::System, v.id, "new").await.unwrap();
    assert!(existed);
    tx.commit().await.unwrap();
    assert!(vocab::vocabulary_by_key(db.pool(), "new").await.unwrap().is_some());
    assert!(vocab::vocabulary_by_key(db.pool(), "old").await.unwrap().is_none());
}

#[sqlx::test(migrations = "../db/migrations")]
async fn delete_vocabulary_blocks_when_it_has_terms(pool: PgPool) {
    use db::DeleteOutcome;
    let db = Db::from_pool(pool);
    let mut tx = db.pool().begin().await.unwrap();
    let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material").await.unwrap();
    vocab::add_term(&mut tx, AuditActor::System, &NewTerm {
        vocabulary_id: v.id, external_uri: None,
        labels: vec![LocalizedLabel { lang: "sv".into(), label: "Trä".into() }],
    }).await.unwrap();

    let blocked = vocab::delete_vocabulary(&mut tx, AuditActor::System, v.id).await.unwrap();
    assert_eq!(blocked, DeleteOutcome::InUse { count: 1 });

    // Empty vocabulary deletes cleanly.
    let empty = vocab::create_vocabulary(&mut tx, AuditActor::System, "empty").await.unwrap();
    assert_eq!(vocab::delete_vocabulary(&mut tx, AuditActor::System, empty.id).await.unwrap(),
               DeleteOutcome::Deleted);
}
```

- [ ] **Step 2: Run to confirm failure.**
```
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test vocab rename_vocabulary delete_vocabulary
```
Expected: FAIL.

- [ ] **Step 3: Implement.** In `crates/db/src/vocab.rs`:
```rust
/// Rename a vocabulary's key, recording an `updated` audit entry. Returns `false` if no
/// such vocabulary. A unique-key collision surfaces as the underlying sqlx error (23505).
pub async fn rename_vocabulary(
    conn: &mut sqlx::PgConnection,
    actor: AuditActor,
    id: VocabularyId,
    key: &str,
) -> Result<bool, sqlx::Error> {
    let updated = sqlx::query("UPDATE vocabulary SET key = $2 WHERE id = $1")
        .bind(id.to_uuid())
        .bind(key)
        .execute(&mut *conn)
        .await?
        .rows_affected();

    if updated == 0 {
        return Ok(false);
    }

    audit::record(&mut *conn, &NewAuditEvent {
        actor, action: AuditAction::Updated,
        entity_type: VOCABULARY_ENTITY_TYPE.to_owned(),
        entity_id: id.to_uuid(), changes: Vec::new(),
    }).await?;

    Ok(true)
}

/// Delete a vocabulary unless it still has terms or is bound by a field definition
/// (both would otherwise hit the FK `RESTRICT`). Records a `deleted` audit entry.
pub async fn delete_vocabulary(
    conn: &mut sqlx::PgConnection,
    actor: AuditActor,
    id: VocabularyId,
) -> Result<crate::DeleteOutcome, sqlx::Error> {
    let exists = sqlx::query_scalar::<_, i32>("SELECT 1 FROM vocabulary WHERE id = $1")
        .bind(id.to_uuid())
        .fetch_optional(&mut *conn)
        .await?;
    if exists.is_none() {
        return Ok(crate::DeleteOutcome::NotFound);
    }

    let count: i64 = sqlx::query_scalar(
        "SELECT (SELECT count(*) FROM term WHERE vocabulary_id = $1) \
              + (SELECT count(*) FROM field_definition WHERE vocabulary_id = $1)",
    )
    .bind(id.to_uuid())
    .fetch_one(&mut *conn)
    .await?;
    if count > 0 {
        return Ok(crate::DeleteOutcome::InUse { count });
    }

    sqlx::query("DELETE FROM vocabulary WHERE id = $1")
        .bind(id.to_uuid())
        .execute(&mut *conn)
        .await?;

    audit::record(&mut *conn, &NewAuditEvent {
        actor, action: AuditAction::Deleted,
        entity_type: VOCABULARY_ENTITY_TYPE.to_owned(),
        entity_id: id.to_uuid(), changes: Vec::new(),
    }).await?;

    Ok(crate::DeleteOutcome::Deleted)
}
```
Add `const VOCABULARY_ENTITY_TYPE: &str = "vocabulary";` already exists in this file (it does). 

- [ ] **Step 4: Run db tests — expect PASS.**
```
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test vocab
```

- [ ] **Step 5: Write failing api tests** in `crates/api/tests/admin_catalog.rs` (reuse the `send` helper from Task 1):
```rust
#[sqlx::test(migrations = "../db/migrations")]
async fn rename_and_delete_vocabulary(pool: PgPool) {
    seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
    let app = build_app(state(pool));
    let cookie = login(&app, "ed@example.com", "pw-editor-123").await;

    let v = send(&app, &cookie, "POST", "/api/admin/vocabularies", Some(r#"{"key":"old"}"#)).await;
    let vid: serde_json::Value = serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap();
    let vid = vid["id"].as_str().unwrap().to_owned();

    let renamed = send(&app, &cookie, "PATCH", &format!("/api/admin/vocabularies/{vid}"),
                       Some(r#"{"key":"new"}"#)).await;
    assert_eq!(renamed.status(), StatusCode::NO_CONTENT);

    let deleted = send(&app, &cookie, "DELETE", &format!("/api/admin/vocabularies/{vid}"), None).await;
    assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
}

#[sqlx::test(migrations = "../db/migrations")]
async fn delete_vocabulary_with_terms_is_409(pool: PgPool) {
    seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
    let app = build_app(state(pool));
    let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
    let v = send(&app, &cookie, "POST", "/api/admin/vocabularies", Some(r#"{"key":"material"}"#)).await;
    let vid: serde_json::Value = serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap();
    let vid = vid["id"].as_str().unwrap().to_owned();
    send(&app, &cookie, "POST", &format!("/api/admin/vocabularies/{vid}/terms"),
         Some(r#"{"external_uri":null,"labels":[{"lang":"sv","label":"Trä"}]}"#)).await;

    let blocked = send(&app, &cookie, "DELETE", &format!("/api/admin/vocabularies/{vid}"), None).await;
    assert_eq!(blocked.status(), StatusCode::CONFLICT);
    let body: serde_json::Value = serde_json::from_slice(&blocked.into_body().collect().await.unwrap().to_bytes()).unwrap();
    assert_eq!(body["count"], 1);
}
```

- [ ] **Step 6: Implement handlers + routes** in `crates/api/src/admin_vocab.rs`:
```rust
#[derive(Deserialize, ToSchema)]
pub(crate) struct RenameVocabularyRequest {
    pub key: String,
}

#[utoipa::path(
    patch, path = "/api/admin/vocabularies/{id}",
    request_body = RenameVocabularyRequest,
    params(("id" = String, Path, description = "Vocabulary id (UUID)")),
    responses((status = 204), (status = 401), (status = 403), (status = 404),
              (status = 409, description = "Key already in use"))
)]
pub(crate) async fn rename_vocabulary(
    auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    Path(id): Path<String>,
    Json(req): Json<RenameVocabularyRequest>,
) -> Result<StatusCode, StatusCode> {
    let id = id.parse::<VocabularyId>().map_err(|_| StatusCode::NOT_FOUND)?;
    let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let existed = db::vocab::rename_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id, &req.key)
        .await
        .map_err(|err| {
            // Unique-key collision → 409.
            if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23505") {
                StatusCode::CONFLICT
            } else {
                StatusCode::INTERNAL_SERVER_ERROR
            }
        })?;
    tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    if existed { Ok(StatusCode::NO_CONTENT) } else { Err(StatusCode::NOT_FOUND) }
}

#[utoipa::path(
    delete, path = "/api/admin/vocabularies/{id}",
    params(("id" = String, Path, description = "Vocabulary id (UUID)")),
    responses((status = 204), (status = 401), (status = 403), (status = 404),
              (status = 409, body = InUseView, description = "Has terms or is bound by a field"))
)]
pub(crate) async fn delete_vocabulary(
    auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> Response {
    let Ok(id) = id.parse::<VocabularyId>() else { return StatusCode::NOT_FOUND.into_response() };
    let Ok(mut tx) = state.db.pool().begin().await else { return StatusCode::INTERNAL_SERVER_ERROR.into_response() };
    match db::vocab::delete_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id).await {
        Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
            Ok(()) => StatusCode::NO_CONTENT.into_response(),
            Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
        },
        Ok(db::DeleteOutcome::InUse { count }) => (StatusCode::CONFLICT, Json(InUseView { count })).into_response(),
        Ok(db::DeleteOutcome::NotFound) => StatusCode::NOT_FOUND.into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}
```
Extend `routes()`:
```rust
.route(
    "/api/admin/vocabularies/{id}",
    axum::routing::patch(rename_vocabulary).delete(delete_vocabulary),
)
```

- [ ] **Step 7: Register in OpenAPI.** Add `admin_vocab::rename_vocabulary,` and `admin_vocab::delete_vocabulary,` to `paths(...)`, and `admin_vocab::RenameVocabularyRequest,` to schemas.

- [ ] **Step 8: fmt, clippy, test.**
```
cargo +nightly fmt && cargo clippy -p api -p db --all-targets -- -D warnings
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api -p db
```
Expected: all PASS.

- [ ] **Step 9: Commit.**
```bash
git add crates/db crates/api
git commit -m "feat: rename + delete vocabularies, blocked when in use (#30)"
```

---

## Task 3: Authority edit/delete (db + api)

**Files:**
- Modify: `crates/db/src/authority.rs`, `crates/api/src/admin_authorities.rs`, `crates/api/src/openapi.rs`
- Test: `crates/db/tests/authority.rs`, `crates/api/tests/admin_catalog.rs`

- [ ] **Step 1: Write failing db tests** in `crates/db/tests/authority.rs` (mirror existing setup: `authority::create_authority` in a tx). For the referenced case, create an `authority`-typed field definition + an object whose `fields` stores the authority id, via `fields::create_field_definition` + `catalog::set_object_fields` (see Task 1 Step 2 for the pattern; use `domain::FieldType::Authority { kind }`).
```rust
#[sqlx::test(migrations = "../db/migrations")]
async fn update_authority_changes_labels(pool: PgPool) {
    let db = Db::from_pool(pool);
    let mut tx = db.pool().begin().await.unwrap();
    let id = authority::create_authority(&mut tx, AuditActor::System, &NewAuthority {
        kind: AuthorityKind::Person, external_uri: None,
        labels: vec![LocalizedLabel { lang: "sv".into(), label: "Anon".into() }],
    }).await.unwrap();

    let existed = authority::update_authority(&mut tx, AuditActor::System, id,
        Some("https://viaf.org/1"),
        &[LocalizedLabel { lang: "sv".into(), label: "Astrid".into() }]).await.unwrap();
    assert!(existed);
    tx.commit().await.unwrap();
    let a = authority::authority_by_id(db.pool(), id).await.unwrap().unwrap();
    assert_eq!(a.external_uri.as_deref(), Some("https://viaf.org/1"));
    assert_eq!(a.labels[0].label, "Astrid");
}

#[sqlx::test(migrations = "../db/migrations")]
async fn delete_authority_blocks_when_referenced(pool: PgPool) {
    use db::{DeleteOutcome, catalog, fields};
    let db = Db::from_pool(pool);
    let mut tx = db.pool().begin().await.unwrap();
    let id = authority::create_authority(&mut tx, AuditActor::System, &NewAuthority {
        kind: AuthorityKind::Person, external_uri: None,
        labels: vec![LocalizedLabel { lang: "sv".into(), label: "Astrid".into() }],
    }).await.unwrap();
    fields::create_field_definition(&mut tx, &NewFieldDefinition {
        key: "maker".into(),
        field_type: domain::FieldType::Authority { kind: AuthorityKind::Person },
        required: false, group_key: None,
        labels: vec![LocalizedLabel { lang: "sv".into(), label: "Tillverkare".into() }],
    }).await.unwrap();
    let obj = catalog::create_object(&mut tx, AuditActor::System, &super::sample_object_input()).await.unwrap();
    let mut map = serde_json::Map::new();
    map.insert("maker".into(), serde_json::Value::String(id.to_string()));
    catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map).await.unwrap();

    assert_eq!(authority::delete_authority(&mut tx, AuditActor::System, id).await.unwrap(),
               DeleteOutcome::InUse { count: 1 });
    catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new()).await.unwrap();
    assert_eq!(authority::delete_authority(&mut tx, AuditActor::System, id).await.unwrap(),
               DeleteOutcome::Deleted);
}
```
(If `sample_object_input()` lives in `vocab.rs` tests, duplicate the tiny helper into `authority.rs` tests rather than cross-referencing — test crates don't share modules. Define it locally.)

- [ ] **Step 2: Run to confirm failure.**
```
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test authority
```

- [ ] **Step 3: Implement** in `crates/db/src/authority.rs` (the file already imports `AuditAction, AuditActor, NewAuditEvent`):
```rust
/// Update an authority's `external_uri` and labels (full replace), recording an
/// `updated` audit entry. Returns `false` if no such authority. `kind` is immutable.
pub async fn update_authority(
    conn: &mut sqlx::PgConnection,
    actor: AuditActor,
    id: AuthorityId,
    external_uri: Option<&str>,
    labels: &[LocalizedLabel],
) -> Result<bool, sqlx::Error> {
    let updated = sqlx::query("UPDATE authority SET external_uri = $2 WHERE id = $1")
        .bind(id.to_uuid())
        .bind(external_uri)
        .execute(&mut *conn)
        .await?
        .rows_affected();
    if updated == 0 {
        return Ok(false);
    }

    sqlx::query("DELETE FROM authority_label WHERE authority_id = $1")
        .bind(id.to_uuid())
        .execute(&mut *conn)
        .await?;
    for label in labels {
        sqlx::query("INSERT INTO authority_label (authority_id, lang, label) VALUES ($1, $2, $3)")
            .bind(id.to_uuid())
            .bind(&label.lang)
            .bind(&label.label)
            .execute(&mut *conn)
            .await?;
    }

    audit::record(&mut *conn, &NewAuditEvent {
        actor, action: AuditAction::Updated,
        entity_type: AUTHORITY_ENTITY_TYPE.to_owned(),
        entity_id: id.to_uuid(), changes: Vec::new(),
    }).await?;
    Ok(true)
}

/// Count catalogue objects referencing `id` through an `authority`-typed field.
pub async fn count_objects_referencing_authority<'e, E>(
    executor: E,
    id: AuthorityId,
) -> Result<i64, sqlx::Error>
where
    E: sqlx::PgExecutor<'e>,
{
    sqlx::query_scalar(
        "SELECT count(*) FROM object o WHERE EXISTS ( \
           SELECT 1 FROM field_definition fd \
           WHERE fd.data_type = 'authority' AND o.fields ->> fd.key = $1 )",
    )
    .bind(id.to_string())
    .fetch_one(executor)
    .await
}

/// Delete an authority (labels cascade) unless catalogue objects reference it,
/// recording a `deleted` audit entry.
pub async fn delete_authority(
    conn: &mut sqlx::PgConnection,
    actor: AuditActor,
    id: AuthorityId,
) -> Result<crate::DeleteOutcome, sqlx::Error> {
    let exists = sqlx::query_scalar::<_, i32>("SELECT 1 FROM authority WHERE id = $1")
        .bind(id.to_uuid())
        .fetch_optional(&mut *conn)
        .await?;
    if exists.is_none() {
        return Ok(crate::DeleteOutcome::NotFound);
    }
    let count = count_objects_referencing_authority(&mut *conn, id).await?;
    if count > 0 {
        return Ok(crate::DeleteOutcome::InUse { count });
    }
    sqlx::query("DELETE FROM authority WHERE id = $1")
        .bind(id.to_uuid())
        .execute(&mut *conn)
        .await?;
    audit::record(&mut *conn, &NewAuditEvent {
        actor, action: AuditAction::Deleted,
        entity_type: AUTHORITY_ENTITY_TYPE.to_owned(),
        entity_id: id.to_uuid(), changes: Vec::new(),
    }).await?;
    Ok(crate::DeleteOutcome::Deleted)
}
```

- [ ] **Step 4: Run db tests — PASS.**
```
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test authority
```

- [ ] **Step 5: Write failing api tests** in `crates/api/tests/admin_catalog.rs`:
```rust
#[sqlx::test(migrations = "../db/migrations")]
async fn edit_and_delete_authority(pool: PgPool) {
    seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
    let app = build_app(state(pool));
    let cookie = login(&app, "ed@example.com", "pw-editor-123").await;

    let a = send(&app, &cookie, "POST", "/api/admin/authorities",
        Some(r#"{"kind":"person","external_uri":null,"labels":[{"lang":"sv","label":"Anon"}]}"#)).await;
    let aid: serde_json::Value = serde_json::from_slice(&a.into_body().collect().await.unwrap().to_bytes()).unwrap();
    let aid = aid["id"].as_str().unwrap().to_owned();

    let patched = send(&app, &cookie, "PATCH", &format!("/api/admin/authorities/{aid}"),
        Some(r#"{"external_uri":"https://viaf.org/1","labels":[{"lang":"sv","label":"Astrid"}]}"#)).await;
    assert_eq!(patched.status(), StatusCode::NO_CONTENT);

    let deleted = send(&app, &cookie, "DELETE", &format!("/api/admin/authorities/{aid}"), None).await;
    assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
}
```

- [ ] **Step 6: Implement handlers + routes** in `crates/api/src/admin_authorities.rs`:
  - Imports: add `AuthorityId` to the `domain` import, `axum::response::{IntoResponse, Response}`, and `use crate::admin_vocab::InUseView;`.
  - DTO + handlers:
```rust
#[derive(Deserialize, ToSchema)]
pub(crate) struct UpdateAuthorityRequest {
    pub external_uri: Option<String>,
    pub labels: Vec<LabelInput>,
}

#[utoipa::path(
    patch, path = "/api/admin/authorities/{id}",
    request_body = UpdateAuthorityRequest,
    params(("id" = String, Path, description = "Authority id (UUID)")),
    responses((status = 204), (status = 401), (status = 403), (status = 404))
)]
pub(crate) async fn update_authority(
    auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    axum::extract::Path(id): axum::extract::Path<String>,
    Json(req): Json<UpdateAuthorityRequest>,
) -> Result<StatusCode, StatusCode> {
    let id = id.parse::<AuthorityId>().map_err(|_| StatusCode::NOT_FOUND)?;
    let labels: Vec<LocalizedLabel> = req.labels.into_iter()
        .map(|l| LocalizedLabel { lang: l.lang, label: l.label }).collect();
    let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let existed = db::authority::update_authority(&mut tx, AuditActor::User(auth.user.id.to_uuid()),
        id, req.external_uri.as_deref(), &labels)
        .await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    if existed { Ok(StatusCode::NO_CONTENT) } else { Err(StatusCode::NOT_FOUND) }
}

#[utoipa::path(
    delete, path = "/api/admin/authorities/{id}",
    params(("id" = String, Path, description = "Authority id (UUID)")),
    responses((status = 204), (status = 401), (status = 403), (status = 404),
              (status = 409, body = InUseView))
)]
pub(crate) async fn delete_authority(
    auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    axum::extract::Path(id): axum::extract::Path<String>,
) -> Response {
    let Ok(id) = id.parse::<AuthorityId>() else { return StatusCode::NOT_FOUND.into_response() };
    let Ok(mut tx) = state.db.pool().begin().await else { return StatusCode::INTERNAL_SERVER_ERROR.into_response() };
    match db::authority::delete_authority(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id).await {
        Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
            Ok(()) => StatusCode::NO_CONTENT.into_response(),
            Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
        },
        Ok(db::DeleteOutcome::InUse { count }) => (StatusCode::CONFLICT, Json(InUseView { count })).into_response(),
        Ok(db::DeleteOutcome::NotFound) => StatusCode::NOT_FOUND.into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}
```
  - Extend `routes()`:
```rust
.route(
    "/api/admin/authorities/{id}",
    axum::routing::patch(update_authority).delete(delete_authority),
)
```

- [ ] **Step 7: Register in OpenAPI.** Add `admin_authorities::update_authority,` and `admin_authorities::delete_authority,` to `paths(...)`, and `admin_authorities::UpdateAuthorityRequest,` to schemas.

- [ ] **Step 8: fmt, clippy, test.**
```
cargo +nightly fmt && cargo clippy -p api -p db --all-targets -- -D warnings
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api -p db
```

- [ ] **Step 9: Commit.**
```bash
git add crates/db crates/api
git commit -m "feat: edit/delete authorities, blocked when referenced (#30)"
```

---

## Task 4: Field-definition edit/delete (db + api)

**Files:**
- Modify: `crates/db/src/fields.rs`, `crates/api/src/admin_objects.rs`, `crates/api/src/openapi.rs`
- Test: `crates/db/tests/fields.rs` (new), `crates/api/tests/admin_catalog.rs`

- [ ] **Step 1: Create `crates/db/tests/fields.rs`** with failing tests (mirror the `Db::from_pool` + tx pattern; reuse a local `sample_object_input()` helper):
```rust
use db::{Db, DeleteOutcome, catalog, fields};
use domain::{AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition};
use sqlx::PgPool;

fn sample_object_input() -> domain::ObjectInput {
    domain::ObjectInput {
        object_number: "X.1".into(), object_name: "Test".into(), number_of_objects: 1,
        brief_description: None, current_location: None, current_owner: None,
        recorder: None, recording_date: None, visibility: domain::Visibility::Draft,
    }
}

#[sqlx::test(migrations = "../db/migrations")]
async fn update_field_definition_edits_labels_group_required(pool: PgPool) {
    let db = Db::from_pool(pool);
    let mut tx = db.pool().begin().await.unwrap();
    fields::create_field_definition(&mut tx, &NewFieldDefinition {
        key: "weight".into(), field_type: FieldType::Integer, required: false, group_key: None,
        labels: vec![LocalizedLabel { lang: "sv".into(), label: "Vikt".into() }],
    }).await.unwrap();

    let existed = fields::update_field_definition(&mut tx, AuditActor::System, "weight",
        true, Some("Mått"),
        &[LocalizedLabel { lang: "sv".into(), label: "Vikt (g)".into() }]).await.unwrap();
    assert!(existed);
    tx.commit().await.unwrap();

    let def = fields::field_definition_by_key(db.pool(), "weight").await.unwrap().unwrap();
    assert!(def.required);
    assert_eq!(def.group_key.as_deref(), Some("Mått"));
    assert_eq!(def.labels[0].label, "Vikt (g)");
}

#[sqlx::test(migrations = "../db/migrations")]
async fn delete_field_definition_blocks_when_objects_use_it(pool: PgPool) {
    let db = Db::from_pool(pool);
    let mut tx = db.pool().begin().await.unwrap();
    fields::create_field_definition(&mut tx, &NewFieldDefinition {
        key: "weight".into(), field_type: FieldType::Integer, required: false, group_key: None,
        labels: vec![LocalizedLabel { lang: "sv".into(), label: "Vikt".into() }],
    }).await.unwrap();
    let obj = catalog::create_object(&mut tx, AuditActor::System, &sample_object_input()).await.unwrap();
    let mut map = serde_json::Map::new();
    map.insert("weight".into(), serde_json::Value::from(42));
    catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map).await.unwrap();

    assert_eq!(fields::delete_field_definition(&mut tx, AuditActor::System, "weight").await.unwrap(),
               DeleteOutcome::InUse { count: 1 });
    catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new()).await.unwrap();
    assert_eq!(fields::delete_field_definition(&mut tx, AuditActor::System, "weight").await.unwrap(),
               DeleteOutcome::Deleted);
    assert_eq!(fields::delete_field_definition(&mut tx, AuditActor::System, "weight").await.unwrap(),
               DeleteOutcome::NotFound);
}
```
(Confirm `domain::FieldType::Integer` is the correct variant name by reading `crates/domain/src/field_definition.rs`; adjust if the integer variant is spelled differently.)

- [ ] **Step 2: Run to confirm failure.**
```
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test fields
```

- [ ] **Step 3: Implement** in `crates/db/src/fields.rs`. Add audit imports + entity const at the top:
```rust
use domain::{
    AuditAction, AuditActor, AuthorityKind, FieldDefinition, FieldDefinitionId, FieldType,
    LocalizedLabel, NewAuditEvent, NewFieldDefinition, VocabularyId,
};
// ...existing `use sqlx::Row;`
use crate::audit;

const FIELD_DEFINITION_ENTITY_TYPE: &str = "field_definition";
```
Then add the functions:
```rust
/// Update a field definition's mutable attributes (labels, group, required); `key`,
/// `data_type`, and binding are immutable and untouched. Records an `updated` audit
/// entry. Returns `false` if no such key. Pass a transaction connection.
pub async fn update_field_definition(
    conn: &mut sqlx::PgConnection,
    actor: AuditActor,
    key: &str,
    required: bool,
    group_key: Option<&str>,
    labels: &[LocalizedLabel],
) -> Result<bool, sqlx::Error> {
    let id: Option<uuid::Uuid> =
        sqlx::query_scalar("SELECT id FROM field_definition WHERE key = $1")
            .bind(key)
            .fetch_optional(&mut *conn)
            .await?;
    let Some(id) = id else { return Ok(false) };

    sqlx::query("UPDATE field_definition SET required = $2, group_key = $3 WHERE id = $1")
        .bind(id)
        .bind(required)
        .bind(group_key)
        .execute(&mut *conn)
        .await?;

    sqlx::query("DELETE FROM field_definition_label WHERE field_definition_id = $1")
        .bind(id)
        .execute(&mut *conn)
        .await?;
    for label in labels {
        sqlx::query(
            "INSERT INTO field_definition_label (field_definition_id, lang, label) VALUES ($1, $2, $3)",
        )
        .bind(id)
        .bind(&label.lang)
        .bind(&label.label)
        .execute(&mut *conn)
        .await?;
    }

    audit::record(&mut *conn, &NewAuditEvent {
        actor, action: AuditAction::Updated,
        entity_type: FIELD_DEFINITION_ENTITY_TYPE.to_owned(),
        entity_id: id, changes: Vec::new(),
    }).await?;
    Ok(true)
}

/// Count catalogue objects that store a value under field `key`.
pub async fn count_objects_using_field<'e, E>(executor: E, key: &str) -> Result<i64, sqlx::Error>
where
    E: sqlx::PgExecutor<'e>,
{
    sqlx::query_scalar("SELECT count(*) FROM object WHERE jsonb_exists(fields, $1)")
        .bind(key)
        .fetch_one(executor)
        .await
}

/// Delete a field definition (labels cascade) unless catalogue objects use its key,
/// recording a `deleted` audit entry.
pub async fn delete_field_definition(
    conn: &mut sqlx::PgConnection,
    actor: AuditActor,
    key: &str,
) -> Result<crate::DeleteOutcome, sqlx::Error> {
    let id: Option<uuid::Uuid> =
        sqlx::query_scalar("SELECT id FROM field_definition WHERE key = $1")
            .bind(key)
            .fetch_optional(&mut *conn)
            .await?;
    let Some(id) = id else { return Ok(crate::DeleteOutcome::NotFound) };

    let count = count_objects_using_field(&mut *conn, key).await?;
    if count > 0 {
        return Ok(crate::DeleteOutcome::InUse { count });
    }

    sqlx::query("DELETE FROM field_definition WHERE id = $1")
        .bind(id)
        .execute(&mut *conn)
        .await?;
    audit::record(&mut *conn, &NewAuditEvent {
        actor, action: AuditAction::Deleted,
        entity_type: FIELD_DEFINITION_ENTITY_TYPE.to_owned(),
        entity_id: id, changes: Vec::new(),
    }).await?;
    Ok(crate::DeleteOutcome::Deleted)
}
```

- [ ] **Step 4: Run db tests — PASS.**
```
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test fields
```

- [ ] **Step 5: Write failing api tests** in `crates/api/tests/admin_catalog.rs`:
```rust
#[sqlx::test(migrations = "../db/migrations")]
async fn edit_and_delete_field_definition(pool: PgPool) {
    seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
    let app = build_app(state(pool));
    let cookie = login(&app, "ed@example.com", "pw-editor-123").await;

    send(&app, &cookie, "POST", "/api/admin/field-definitions",
        Some(r#"{"key":"weight","data_type":"integer","vocabulary_id":null,"authority_kind":null,"required":false,"group":null,"labels":[{"lang":"sv","label":"Vikt"}]}"#)).await;

    let patched = send(&app, &cookie, "PATCH", "/api/admin/field-definitions/weight",
        Some(r#"{"required":true,"group":"Mått","labels":[{"lang":"sv","label":"Vikt (g)"}]}"#)).await;
    assert_eq!(patched.status(), StatusCode::NO_CONTENT);

    let deleted = send(&app, &cookie, "DELETE", "/api/admin/field-definitions/weight", None).await;
    assert_eq!(deleted.status(), StatusCode::NO_CONTENT);

    let again = send(&app, &cookie, "DELETE", "/api/admin/field-definitions/weight", None).await;
    assert_eq!(again.status(), StatusCode::NOT_FOUND);
}
```

- [ ] **Step 6: Implement handlers + routes** in `crates/api/src/admin_objects.rs`. The file already has `actor(&auth.user)`, `Authorized<EditCatalogue>`, `LabelView`, `LabelInput` (imported via `admin_vocab`). Add `axum::response::{IntoResponse, Response}` and `use crate::admin_vocab::InUseView;`. Add:
```rust
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub(crate) struct UpdateFieldDefinitionRequest {
    pub required: bool,
    pub group: Option<String>,
    pub labels: Vec<LabelInput>,
}

#[utoipa::path(
    patch, path = "/api/admin/field-definitions/{key}",
    request_body = UpdateFieldDefinitionRequest,
    params(("key" = String, Path, description = "Field key")),
    responses((status = 204), (status = 401), (status = 403), (status = 404),
              (status = 422, description = "Invalid value (e.g. empty label/group)"))
)]
pub(crate) async fn update_field_definition(
    auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    Path(key): Path<String>,
    Json(req): Json<UpdateFieldDefinitionRequest>,
) -> Result<StatusCode, StatusCode> {
    let labels: Vec<LocalizedLabel> = req.labels.into_iter()
        .map(|l| LocalizedLabel { lang: l.lang, label: l.label }).collect();
    let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let existed = db::fields::update_field_definition(
        &mut tx, actor(&auth.user), &key, req.required, req.group.as_deref(), &labels,
    ).await.map_err(|err| {
        // CHECK constraint (empty label / group_key) → 422.
        if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23514") {
            StatusCode::UNPROCESSABLE_ENTITY
        } else {
            StatusCode::INTERNAL_SERVER_ERROR
        }
    })?;
    tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    if existed { Ok(StatusCode::NO_CONTENT) } else { Err(StatusCode::NOT_FOUND) }
}

#[utoipa::path(
    delete, path = "/api/admin/field-definitions/{key}",
    params(("key" = String, Path, description = "Field key")),
    responses((status = 204), (status = 401), (status = 403), (status = 404),
              (status = 409, body = InUseView))
)]
pub(crate) async fn delete_field_definition(
    auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    Path(key): Path<String>,
) -> Response {
    let Ok(mut tx) = state.db.pool().begin().await else { return StatusCode::INTERNAL_SERVER_ERROR.into_response() };
    match db::fields::delete_field_definition(&mut tx, actor(&auth.user), &key).await {
        Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
            Ok(()) => StatusCode::NO_CONTENT.into_response(),
            Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
        },
        Ok(db::DeleteOutcome::InUse { count }) => (StatusCode::CONFLICT, Json(InUseView { count })).into_response(),
        Ok(db::DeleteOutcome::NotFound) => StatusCode::NOT_FOUND.into_response(),
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}
```
Find where field-definition routes are registered (search `field-definitions` in `admin_objects.rs`'s `routes()`), and extend that route:
```rust
.route(
    "/api/admin/field-definitions/{key}",
    axum::routing::patch(update_field_definition).delete(delete_field_definition),
)
```
(If there is no existing `/{key}` route, add it as a new `.route(...)`.)

- [ ] **Step 7: Register in OpenAPI.** Add `admin_objects::update_field_definition,` and `admin_objects::delete_field_definition,` to `paths(...)`, and `admin_objects::UpdateFieldDefinitionRequest,` to schemas.

- [ ] **Step 8: fmt, clippy, full backend test.**
```
cargo +nightly fmt && cargo clippy --workspace --all-targets -- -D warnings
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace
```
Expected: all PASS, clippy clean.

- [ ] **Step 9: Commit.**
```bash
git add crates/db crates/api
git commit -m "feat: edit/delete field definitions — audited, blocked when in use (#36)"
```

---

## Task 5: Regenerate the web API types

**Files:** `web/src/api/schema.d.ts` (generated)

- [ ] **Step 1: Start the stack and server.** Ensure compose is up, then run the server so `pnpm gen:api` can read `/api-docs/openapi.json`:
```bash
docker compose up -d            # postgres + meilisearch
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev \
MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey \
cargo run -p server &           # background; wait until it logs "listening"
```

- [ ] **Step 2: Regenerate.**
```bash
cd web && pnpm gen:api
```

- [ ] **Step 3: Verify the new endpoints/DTOs landed.**
```bash
grep -c "InUseView" web/src/api/schema.d.ts          # >= 1
grep -c "UpdateTermRequest" web/src/api/schema.d.ts  # >= 1
grep -c "/api/admin/field-definitions/{key}" web/src/api/schema.d.ts  # >= 1
```
Expected: each ≥ 1. Stop the background server afterward (`kill %1` or Ctrl-C).

- [ ] **Step 4: typecheck (no app code changed yet, just the schema).**
```bash
cd web && pnpm typecheck
```
Expected: PASS.

- [ ] **Step 5: Commit.**
```bash
git add web/src/api/schema.d.ts
git commit -m "chore(web): regenerate API types for reference-data edit/delete"
```

---

## Task 6: Frontend data layer — mutation hooks + i18n

**Files:**
- Modify: `web/src/api/queries.ts`
- Modify: `web/src/i18n/en.json`, `web/src/i18n/sv.json`
- Test: `web/src/api/queries.test.ts` (new, or extend an existing queries test if present)

- [ ] **Step 1: Add i18n keys.** In `web/src/i18n/en.json`, extend the existing `actions` object (currently `{ "edit", "delete", "confirmDelete" }`) and add reference-data confirm strings + the in-use message:
```jsonc
"actions": {
  "edit": "Edit", "delete": "Delete", "rename": "Rename", "save": "Save",
  "confirmDelete": "Delete this object? This cannot be undone.",
  "confirmDeleteTerm": "Delete this term? This cannot be undone.",
  "confirmDeleteAuthority": "Delete this authority? This cannot be undone.",
  "confirmDeleteField": "Delete this field definition? This cannot be undone.",
  "confirmDeleteVocabulary": "Delete this vocabulary? This cannot be undone.",
  "inUse": "Can't delete — used by {{count}} object(s). Clear those fields first."
},
```
In `web/src/i18n/sv.json`, mirror with Swedish (keep the same keys):
```jsonc
"actions": {
  "edit": "Redigera", "delete": "Ta bort", "rename": "Byt namn", "save": "Spara",
  "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras.",
  "confirmDeleteTerm": "Ta bort denna term? Detta kan inte ångras.",
  "confirmDeleteAuthority": "Ta bort denna auktoritet? Detta kan inte ångras.",
  "confirmDeleteField": "Ta bort denna fältdefinition? Detta kan inte ångras.",
  "confirmDeleteVocabulary": "Ta bort denna vokabulär? Detta kan inte ångras.",
  "inUse": "Kan inte ta bort — används av {{count}} föremål. Rensa de fälten först."
},
```

- [ ] **Step 2: Write a failing test** for the in-use parsing, in `web/src/api/queries.test.ts` (mirror existing query tests' use of MSW handlers from `web/src/test/handlers.ts` and a `QueryClientProvider` + `renderHook` wrapper — copy the wrapper from any existing `*.test.tsx` that calls `renderHook`). Minimal:
```ts
import { describe, it, expect } from "vitest";
import { InUseError } from "./queries";

describe("InUseError", () => {
  it("carries the count", () => {
    const e = new InUseError(7);
    expect(e.count).toBe(7);
    expect(e).toBeInstanceOf(Error);
  });
});
```

- [ ] **Step 3: Run to confirm failure.**
```bash
cd web && pnpm test -- queries.test
```
Expected: FAIL — `InUseError` not exported.

- [ ] **Step 4: Implement hooks + `InUseError`** in `web/src/api/queries.ts`. Add the error class near `HttpError`/`FieldRejection`:
```ts
export class InUseError extends Error {
  constructor(public readonly count: number) {
    super(`in use: ${count}`);
    this.name = "InUseError";
  }
}
```
Add the 8 hooks (mirroring `useUpdateObject`/`useDeleteObject` and the create hooks). Use the existing `LabelInput` type alias:
```ts
export function useUpdateTerm() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async ({ vocabularyId, termId, external_uri, labels }:
      { vocabularyId: string; termId: string; external_uri: string | null; labels: LabelInput[] }) => {
      const { response } = await api.PATCH("/api/admin/vocabularies/{id}/terms/{term_id}", {
        params: { path: { id: vocabularyId, term_id: termId } },
        body: { external_uri, labels },
      });
      if (response.status !== 204) throw new Error("update term failed");
    },
    onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
  });
}

export function useDeleteTerm() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async ({ vocabularyId, termId }: { vocabularyId: string; termId: string }) => {
      const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}/terms/{term_id}", {
        params: { path: { id: vocabularyId, term_id: termId } },
      });
      if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
      if (response.status !== 204) throw new Error("delete term failed");
    },
    onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
  });
}

export function useRenameVocabulary() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async ({ id, key }: { id: string; key: string }) => {
      const { response } = await api.PATCH("/api/admin/vocabularies/{id}", {
        params: { path: { id } }, body: { key },
      });
      if (response.status !== 204) throw new Error("rename failed");
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
  });
}

export function useDeleteVocabulary() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async (id: string) => {
      const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}", {
        params: { path: { id } },
      });
      if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
      if (response.status !== 204) throw new Error("delete vocabulary failed");
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
  });
}

export function useUpdateAuthority() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async ({ id, external_uri, labels }:
      { id: string; kind: string; external_uri: string | null; labels: LabelInput[] }) => {
      const { response } = await api.PATCH("/api/admin/authorities/{id}", {
        params: { path: { id } }, body: { external_uri, labels },
      });
      if (response.status !== 204) throw new Error("update authority failed");
    },
    onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }),
  });
}

export function useDeleteAuthority() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async ({ id }: { id: string; kind: string }) => {
      const { error, response } = await api.DELETE("/api/admin/authorities/{id}", {
        params: { path: { id } },
      });
      if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
      if (response.status !== 204) throw new Error("delete authority failed");
    },
    onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }),
  });
}

export function useUpdateFieldDefinition() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async ({ key, required, group, labels }:
      { key: string; required: boolean; group: string | null; labels: LabelInput[] }) => {
      const { response } = await api.PATCH("/api/admin/field-definitions/{key}", {
        params: { path: { key } }, body: { required, group, labels },
      });
      if (response.status !== 204) throw new Error("update field failed");
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }),
  });
}

export function useDeleteFieldDefinition() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async (key: string) => {
      const { error, response } = await api.DELETE("/api/admin/field-definitions/{key}", {
        params: { path: { key } },
      });
      if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
      if (response.status !== 204) throw new Error("delete field failed");
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }),
  });
}
```

- [ ] **Step 5: Run test, typecheck, lint.**
```bash
cd web && pnpm test -- queries.test && pnpm typecheck && pnpm lint
```
Expected: PASS, no `any` lint errors (note the `(error as { count?: number })` cast is a *typed* cast, not `any`).

- [ ] **Step 6: Commit.**
```bash
git add web/src/api/queries.ts web/src/api/queries.test.ts web/src/i18n/en.json web/src/i18n/sv.json
git commit -m "feat(web): mutation hooks + InUseError + i18n for reference-data edit/delete"
```

---

## Task 7: Generic delete-confirm dialog (component + story)

**Files:**
- Create: `web/src/components/delete-confirm-dialog.tsx`, `web/src/components/delete-confirm-dialog.stories.tsx`
- Test: covered by the story `play` + reused in Tasks 8–10.

This is a reusable component mirroring `DeleteObjectDialog` but parameterized and aware of `InUseError` (shows the in-use message and keeps the dialog open instead of navigating).

- [ ] **Step 1: Write the component.** `web/src/components/delete-confirm-dialog.tsx`:
```tsx
import { useState } from "react";
import { useTranslation } from "react-i18next";

import { InUseError } from "../api/queries";
import {
  AlertDialog,
  AlertDialogTrigger,
  AlertDialogContent,
  AlertDialogTitle,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogCancel,
  AlertDialogAction,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";

export function DeleteConfirmDialog({
  description,
  onConfirm,
  triggerLabel,
}: {
  /** Confirmation prompt, e.g. t("actions.confirmDeleteTerm"). */
  description: string;
  /** Performs the delete; may throw InUseError to surface the in-use count. */
  onConfirm: () => Promise<void>;
  /** Optional override for the trigger button text (defaults to actions.delete). */
  triggerLabel?: string;
}) {
  const { t } = useTranslation();
  const [open, setOpen] = useState(false);
  const [message, setMessage] = useState<string | null>(null);

  const confirm = async () => {
    setMessage(null);
    try {
      await onConfirm();
    } catch (err) {
      // Keep the dialog open; show the blocking reason. Never let the rejected
      // mutation escape as an unhandled rejection.
      setMessage(err instanceof InUseError ? t("actions.inUse", { count: err.count }) : t("form.rejected"));
      return;
    }
    setOpen(false);
  };

  return (
    <AlertDialog open={open} onOpenChange={setOpen}>
      <AlertDialogTrigger
        render={
          <Button variant="ghost" size="sm" className="text-red-600">
            {triggerLabel ?? t("actions.delete")}
          </Button>
        }
      />
      <AlertDialogContent>
        <AlertDialogTitle>{t("actions.delete")}</AlertDialogTitle>
        <AlertDialogDescription>{description}</AlertDialogDescription>
        {message && (
          <p role="alert" className="text-sm text-red-600">
            {message}
          </p>
        )}
        <AlertDialogFooter>
          <AlertDialogCancel>{t("form.cancel")}</AlertDialogCancel>
          <AlertDialogAction onClick={confirm}>{t("actions.delete")}</AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}
```

- [ ] **Step 2: Write the story** `web/src/components/delete-confirm-dialog.stories.tsx` (mirror the format of `web/src/objects/visibility-badge.stories.tsx` — `@storybook/react-vite`, `expect` from `storybook/test`, `tags: ['ai-generated']`, single quotes, no semicolons):
```tsx
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect, userEvent, fn } from 'storybook/test'

import { DeleteConfirmDialog } from './delete-confirm-dialog'
import { InUseError } from '../api/queries'

const meta = {
  component: DeleteConfirmDialog,
  tags: ['ai-generated'],
} satisfies Meta<typeof DeleteConfirmDialog>

export default meta
type Story = StoryObj<typeof meta>

export const Confirms: Story = {
  args: { description: 'Delete this term? This cannot be undone.', onConfirm: fn() },
  play: async ({ canvas, args }) => {
    await userEvent.click(canvas.getByRole('button', { name: 'Delete' }))
    // The dialog content renders in a portal; query the document body.
    const confirm = await within(document.body).findByRole('button', { name: 'Delete' })
    await userEvent.click(confirm)
    await expect(args.onConfirm).toHaveBeenCalled()
  },
}

export const ShowsInUse: Story = {
  args: {
    description: 'Delete this term? This cannot be undone.',
    onConfirm: async () => { throw new InUseError(7) },
  },
  play: async ({ canvas }) => {
    await userEvent.click(canvas.getByRole('button', { name: 'Delete' }))
    const confirm = await within(document.body).findByRole('button', { name: 'Delete' })
    await userEvent.click(confirm)
    await expect(await within(document.body).findByRole('alert')).toHaveTextContent(/used by 7/i)
  },
}
```
Add `within` to the import from `storybook/test` (i.e. `import { expect, userEvent, fn, within } from 'storybook/test'`). If `AlertDialog` does not portal in the test environment, query via `canvas` instead of `within(document.body)` — run the story to confirm which.

- [ ] **Step 3: Run the stories.**
```bash
cd web && pnpm test -- delete-confirm-dialog
```
Expected: PASS (both stories). If the portal query fails, switch to `canvas` and re-run.

- [ ] **Step 4: typecheck + lint + commit.**
```bash
cd web && pnpm typecheck && pnpm lint
git add web/src/components/delete-confirm-dialog.tsx web/src/components/delete-confirm-dialog.stories.tsx
git commit -m "feat(web): reusable DeleteConfirmDialog with in-use handling + stories"
```

---

## Task 8: Fields screen — edit/delete UI + stories

**Files:**
- Modify: `web/src/fields/fields-page.tsx`, `web/src/fields/field-list.tsx`, `web/src/fields/field-form.tsx`
- Create: `web/src/fields/field-form.stories.tsx`
- Test: story `play` + existing field tests.

The right pane (`FieldForm`) becomes create-or-edit; the left list selects a row and offers delete.

- [ ] **Step 1: Lift selection state into `FieldsPage`** (`web/src/fields/fields-page.tsx`):
```tsx
import { useState } from "react";

import type { components } from "../api/schema";
import { FieldList } from "./field-list";
import { FieldForm } from "./field-form";

type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];

export function FieldsPage() {
  const [selected, setSelected] = useState<FieldDefinitionView | null>(null);

  return (
    <div className="grid h-full grid-cols-[20rem_1fr]">
      <div className="overflow-hidden border-r">
        <FieldList selectedKey={selected?.key ?? null} onSelect={setSelected} />
      </div>
      <div className="overflow-hidden">
        <FieldForm editing={selected} onDone={() => setSelected(null)} />
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Extend `FieldList`** (`web/src/fields/field-list.tsx`) with `onSelect`/`selectedKey` props, a clickable row (selects → edit), and a delete affordance using `DeleteConfirmDialog` + `useDeleteFieldDefinition`. Change the component signature and the row markup:
```tsx
import { useTranslation } from "react-i18next";

import type { components } from "../api/schema";
import { useFieldDefinitions, useDeleteFieldDefinition } from "../api/queries";
import { labelText } from "../lib/labels";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
import { Skeleton } from "@/components/ui/skeleton";

type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];

export function FieldList({
  selectedKey,
  onSelect,
}: {
  selectedKey: string | null;
  onSelect: (def: FieldDefinitionView) => void;
}) {
  const { t, i18n } = useTranslation();
  const { data, isLoading, isError } = useFieldDefinitions();
  const del = useDeleteFieldDefinition();
  const lang = i18n.language.startsWith("sv") ? "sv" : "en";

  // ...keep the existing loading / error / empty branches and group-building logic...

  // Replace the inner row <li> with:
  // {defs.map((def) => (
  //   <li key={def.key}
  //       className={`flex items-center gap-2 border-b px-3 py-2 text-sm ${
  //         def.key === selectedKey ? "bg-indigo-50" : ""}`}>
  //     <button type="button" className="flex flex-1 items-center gap-2 text-left"
  //             onClick={() => onSelect(def)}>
  //       <span className="font-medium">{labelText(def.labels, lang)}</span>
  //       <span className="text-xs text-neutral-400">{def.key}</span>
  //       <span className="rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-600">
  //         {t(`fields.types.${def.data_type}`)}
  //       </span>
  //       {def.required && (
  //         <span className="text-xs text-red-600" title={t("fields.required")}
  //               aria-label={t("fields.required")}>*</span>
  //       )}
  //     </button>
  //     <DeleteConfirmDialog
  //       description={t("actions.confirmDeleteField")}
  //       onConfirm={() => del.mutateAsync(def.key)}
  //     />
  //   </li>
  // ))}
}
```
(Keep the existing group sorting/skeleton code unchanged — only the row markup and the props/imports change.)

- [ ] **Step 3: Make `FieldForm` create-or-edit** (`web/src/fields/field-form.tsx`). Add `editing`/`onDone` props; when `editing` is set, hydrate state from it, disable `key`/type/binding inputs, and submit via `useUpdateFieldDefinition`; otherwise behave as today.
```tsx
import { useEffect, useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";

import type { components } from "../api/schema";
import {
  useCreateFieldDefinition,
  useUpdateFieldDefinition,
  useVocabularies,
} from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";

type LabelInput = components["schemas"]["LabelInput"];
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];

const TYPES = ["text", "localized_text", "integer", "date", "boolean", "term", "authority"] as const;
const KINDS = ["person", "organisation", "place"] as const;

export function FieldForm({
  editing,
  onDone,
}: {
  editing: FieldDefinitionView | null;
  onDone: () => void;
}) {
  const { t } = useTranslation();
  const create = useCreateFieldDefinition();
  const update = useUpdateFieldDefinition();
  const { data: vocabularies } = useVocabularies();

  const isEdit = editing !== null;

  const [key, setKey] = useState("");
  const [labels, setLabels] = useState<LabelInput[]>([]);
  const [dataType, setDataType] = useState<string>("text");
  const [vocabularyId, setVocabularyId] = useState("");
  const [authorityKind, setAuthorityKind] = useState("");
  const [group, setGroup] = useState("");
  const [required, setRequired] = useState(false);
  const [error, setError] = useState(false);

  // Hydrate when the selected definition changes (or reset to create mode).
  useEffect(() => {
    if (editing) {
      setKey(editing.key);
      setLabels(editing.labels as LabelInput[]);
      setDataType(editing.data_type);
      setVocabularyId(editing.vocabulary_id ?? "");
      setAuthorityKind(editing.authority_kind ?? "");
      setGroup(editing.group ?? "");
      setRequired(editing.required);
      setError(false);
    } else {
      setKey(""); setLabels([]); setDataType("text"); setVocabularyId("");
      setAuthorityKind(""); setGroup(""); setRequired(false); setError(false);
    }
  }, [editing]);

  const onSubmit = (event: FormEvent) => {
    event.preventDefault();
    const hasLabel = labels.some((l) => l.label);
    if (!hasLabel || (!isEdit && !key.trim()) || (!isEdit && dataType === "term" && !vocabularyId)) {
      setError(true);
      return;
    }
    setError(false);

    if (isEdit) {
      update.mutate(
        { key: editing.key, required, group: group.trim() || null, labels },
        { onSuccess: onDone },
      );
    } else {
      create.mutate(
        {
          key: key.trim(), data_type: dataType,
          vocabulary_id: dataType === "term" ? vocabularyId : null,
          authority_kind: dataType === "authority" ? authorityKind || null : null,
          required, group: group.trim() || null, labels,
        },
        { onSuccess: onDone },
      );
    }
  };

  const pending = isEdit ? update.isPending : create.isPending;
  const failed = isEdit ? update.isError : create.isError;

  return (
    <form onSubmit={onSubmit} className="space-y-3 overflow-auto p-4">
      <div className="flex items-center justify-between">
        <div className="text-sm font-medium">{isEdit ? labelTextOrKey(editing) : t("fields.newField")}</div>
        {isEdit && (
          <Button type="button" variant="ghost" size="sm" onClick={onDone}>
            {t("fields.newField")}
          </Button>
        )}
      </div>

      <div className="space-y-1">
        <Label htmlFor="field-key">{t("fields.key")}</Label>
        <Input id="field-key" value={key} disabled={isEdit} onChange={(e) => setKey(e.target.value)} />
      </div>

      <LabelEditor value={labels} onChange={setLabels} />

      <div className="space-y-1">
        <Label htmlFor="field-type">{t("fields.type")}</Label>
        <select id="field-type" value={dataType} disabled={isEdit}
                onChange={(e) => setDataType(e.target.value)}
                className="w-full rounded border px-2 py-1 text-sm disabled:opacity-60">
          {TYPES.map((type) => (
            <option key={type} value={type}>{t(`fields.types.${type}`)}</option>
          ))}
        </select>
      </div>

      {dataType === "term" && (
        <div className="space-y-1">
          <Label htmlFor="field-vocab">{t("fields.vocabulary")}</Label>
          <select id="field-vocab" value={vocabularyId} disabled={isEdit}
                  onChange={(e) => setVocabularyId(e.target.value)}
                  className="w-full rounded border px-2 py-1 text-sm disabled:opacity-60">
            <option value="">{t("form.selectPlaceholder")}</option>
            {vocabularies?.map((vocab) => (
              <option key={vocab.id} value={vocab.id}>{vocab.key}</option>
            ))}
          </select>
        </div>
      )}

      {dataType === "authority" && (
        <div className="space-y-1">
          <Label htmlFor="field-kind">{t("fields.authorityKind")}</Label>
          <select id="field-kind" value={authorityKind} disabled={isEdit}
                  onChange={(e) => setAuthorityKind(e.target.value)}
                  className="w-full rounded border px-2 py-1 text-sm disabled:opacity-60">
            <option value="">{t("fields.anyKind")}</option>
            {KINDS.map((kind) => (
              <option key={kind} value={kind}>{t(`authorities.${kind}`)}</option>
            ))}
          </select>
        </div>
      )}

      <div className="space-y-1">
        <Label htmlFor="field-group">{t("fields.group")}</Label>
        <Input id="field-group" value={group} onChange={(e) => setGroup(e.target.value)} />
      </div>

      <label className="flex items-center gap-2 text-sm">
        <Checkbox checked={required} onCheckedChange={(checked) => setRequired(checked === true)} />
        {t("fields.required")}
      </label>

      {error && <p role="alert" className="text-xs text-red-600">{t("form.required")}</p>}
      {failed && <p role="alert" className="text-xs text-red-600">{t("form.rejected")}</p>}

      <Button type="submit" size="sm" disabled={pending}>
        {isEdit ? t("actions.save") : t("fields.create")}
      </Button>
    </form>
  );
}

function labelTextOrKey(def: FieldDefinitionView): string {
  return def.labels[0]?.label ?? def.key;
}
```

- [ ] **Step 4: Write the story** `web/src/fields/field-form.stories.tsx` — assert edit mode disables the key input and shows a Save button (the story mirrors the established format; the form needs the provider tree, which `.storybook/preview.tsx` already supplies):
```tsx
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect, fn } from 'storybook/test'

import { FieldForm } from './field-form'

const meta = {
  component: FieldForm,
  tags: ['ai-generated'],
} satisfies Meta<typeof FieldForm>

export default meta
type Story = StoryObj<typeof meta>

export const Create: Story = {
  args: { editing: null, onDone: fn() },
  play: async ({ canvas }) => {
    await expect(canvas.getByLabelText('Key')).toBeEnabled()
  },
}

export const Edit: Story = {
  args: {
    editing: {
      key: 'material', data_type: 'text', vocabulary_id: null, authority_kind: null,
      required: true, group: 'Identification',
      labels: [{ lang: 'en', label: 'Material' }],
    },
    onDone: fn(),
  },
  play: async ({ canvas }) => {
    await expect(canvas.getByLabelText('Key')).toBeDisabled()
    await expect(canvas.getByRole('button', { name: 'Save' })).toBeVisible()
  },
}
```

- [ ] **Step 5: Run the relevant tests + typecheck + lint.**
```bash
cd web && pnpm test -- field-form fields && pnpm typecheck && pnpm lint
```
Expected: PASS. (If existing `field-list`/`fields-page` tests exist, update any that render `<FieldList/>`/`<FieldsPage/>` to pass the new props or use `<FieldsPage/>` which wires them.)

- [ ] **Step 6: Commit.**
```bash
git add web/src/fields
git commit -m "feat(web): edit/delete field definitions on /fields (in-place edit pane) (#36)"
```

---

## Task 9: Vocabularies screen — rename + term edit/delete + stories

**Files:**
- Modify: `web/src/vocab/vocabulary-list.tsx`, `web/src/vocab/vocabulary-terms.tsx`
- Create: `web/src/vocab/term-row.tsx`, `web/src/vocab/term-row.stories.tsx`
- Test: story `play` + existing vocab tests.

- [ ] **Step 1: Add rename + delete to `VocabularyList`** rows. Each row keeps the `NavLink` but gains an inline rename (toggle an `Input` + Save) using `useRenameVocabulary`, and a `DeleteConfirmDialog` using `useDeleteVocabulary`. Add local `editingId`/`draftKey` state:
```tsx
// imports add:
import { useVocabularies, useCreateVocabulary, useRenameVocabulary, useDeleteVocabulary } from "../api/queries";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
// inside component:
const rename = useRenameVocabulary();
const del = useDeleteVocabulary();
const [editingId, setEditingId] = useState<string | null>(null);
const [draftKey, setDraftKey] = useState("");
// row markup (replace the existing <li> body):
// {data?.map((v) => (
//   <li key={v.id} className="flex items-center gap-1 border-b pr-2">
//     {editingId === v.id ? (
//       <form className="flex flex-1 gap-1 p-1"
//             onSubmit={(e) => { e.preventDefault();
//               rename.mutate({ id: v.id, key: draftKey.trim() },
//                 { onSuccess: () => setEditingId(null) }); }}>
//         <Input value={draftKey} onChange={(e) => setDraftKey(e.target.value)} />
//         <Button type="submit" size="sm">{t("actions.save")}</Button>
//       </form>
//     ) : (
//       <>
//         <NavLink to={`/vocabularies/${v.id}`}
//           className={({ isActive }) =>
//             `block flex-1 px-3 py-2 text-sm ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}`}>
//           {v.key}
//         </NavLink>
//         <Button type="button" variant="ghost" size="sm"
//                 onClick={() => { setEditingId(v.id); setDraftKey(v.key); }}>
//           {t("actions.rename")}
//         </Button>
//         <DeleteConfirmDialog description={t("actions.confirmDeleteVocabulary")}
//                              onConfirm={() => del.mutateAsync(v.id)} />
//       </>
//     )}
//   </li>
// ))}
```
(Keep the existing create form above the list unchanged.)

- [ ] **Step 2: Extract `TermRow`** (`web/src/vocab/term-row.tsx`) — a display row that toggles to an inline edit form (LabelEditor + URI + Save/Cancel) via `useUpdateTerm`, plus a `DeleteConfirmDialog` via `useDeleteTerm`:
```tsx
import { useState } from "react";
import { useTranslation } from "react-i18next";

import type { components } from "../api/schema";
import { useUpdateTerm, useDeleteTerm } from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { labelText } from "../lib/labels";

type TermView = components["schemas"]["TermView"];
type LabelInput = components["schemas"]["LabelInput"];

export function TermRow({ vocabularyId, term, lang }: { vocabularyId: string; term: TermView; lang: string }) {
  const { t } = useTranslation();
  const update = useUpdateTerm();
  const del = useDeleteTerm();
  const [editing, setEditing] = useState(false);
  const [labels, setLabels] = useState<LabelInput[]>(term.labels as LabelInput[]);
  const [uri, setUri] = useState(term.external_uri ?? "");

  if (editing) {
    return (
      <li className="space-y-2 border-b py-2">
        <LabelEditor value={labels} onChange={setLabels} />
        <div className="space-y-1">
          <Label htmlFor={`term-uri-${term.id}`}>{t("labels.externalUri")}</Label>
          <Input id={`term-uri-${term.id}`} value={uri} onChange={(e) => setUri(e.target.value)} />
        </div>
        <div className="flex gap-2">
          <Button type="button" size="sm" disabled={update.isPending}
            onClick={() => update.mutate(
              { vocabularyId, termId: term.id, external_uri: uri.trim() || null, labels },
              { onSuccess: () => setEditing(false) })}>
            {t("actions.save")}
          </Button>
          <Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
            {t("form.cancel")}
          </Button>
        </div>
      </li>
    );
  }

  return (
    <li className="flex items-center gap-2 border-b py-1 text-sm">
      <span className="flex-1">{labelText(term.labels, lang)}</span>
      <Button type="button" variant="ghost" size="sm" onClick={() => setEditing(true)}>
        {t("actions.edit")}
      </Button>
      <DeleteConfirmDialog description={t("actions.confirmDeleteTerm")}
        onConfirm={() => del.mutateAsync({ vocabularyId, termId: term.id })} />
    </li>
  );
}
```

- [ ] **Step 3: Use `TermRow` in `VocabularyTerms`** — replace the inline term `<li>` mapping with `<TermRow vocabularyId={id} term={term} lang={lang} />`. Keep the add-term form unchanged. Add `import { TermRow } from "./term-row";`.

- [ ] **Step 4: Write a story** `web/src/vocab/term-row.stories.tsx` (the row uses hooks that hit the API; rely on the preview MSW handlers — assert the display + edit toggle which need no network):
```tsx
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect, userEvent } from 'storybook/test'

import { TermRow } from './term-row'

const meta = {
  component: TermRow,
  tags: ['ai-generated'],
  args: {
    vocabularyId: 'v1',
    lang: 'en',
    term: { id: 't1', external_uri: null, labels: [{ lang: 'en', label: 'Wood' }] },
  },
} satisfies Meta<typeof TermRow>

export default meta
type Story = StoryObj<typeof meta>

export const Display: Story = {
  play: async ({ canvas }) => {
    await expect(canvas.getByText('Wood')).toBeVisible()
  },
}

export const TogglesEdit: Story = {
  play: async ({ canvas }) => {
    await userEvent.click(canvas.getByRole('button', { name: 'Edit' }))
    await expect(canvas.getByRole('button', { name: 'Save' })).toBeVisible()
  },
}
```

- [ ] **Step 5: Run tests + typecheck + lint.**
```bash
cd web && pnpm test -- term-row vocab && pnpm typecheck && pnpm lint
```
Expected: PASS. Update any existing vocab test that asserts the old plain term `<li>` markup.

- [ ] **Step 6: Commit.**
```bash
git add web/src/vocab
git commit -m "feat(web): rename vocabularies + edit/delete terms in place (#30)"
```

---

## Task 10: Authorities screen — edit/delete + stories

**Files:**
- Modify: `web/src/authorities/authorities-page.tsx`
- Create: `web/src/authorities/authority-row.tsx`, `web/src/authorities/authority-row.stories.tsx`
- Test: story `play` + existing authority tests.

- [ ] **Step 1: Extract `AuthorityRow`** (`web/src/authorities/authority-row.tsx`) mirroring `TermRow` but using `useUpdateAuthority`/`useDeleteAuthority` (both need `kind` for query invalidation):
```tsx
import { useState } from "react";
import { useTranslation } from "react-i18next";

import type { components } from "../api/schema";
import { useUpdateAuthority, useDeleteAuthority } from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { labelText } from "../lib/labels";

type AuthorityView = components["schemas"]["AuthorityView"];
type LabelInput = components["schemas"]["LabelInput"];

export function AuthorityRow({ authority, kind, lang }: { authority: AuthorityView; kind: string; lang: string }) {
  const { t } = useTranslation();
  const update = useUpdateAuthority();
  const del = useDeleteAuthority();
  const [editing, setEditing] = useState(false);
  const [labels, setLabels] = useState<LabelInput[]>(authority.labels as LabelInput[]);
  const [uri, setUri] = useState(authority.external_uri ?? "");

  if (editing) {
    return (
      <li className="space-y-2 border-b py-2">
        <LabelEditor value={labels} onChange={setLabels} />
        <div className="space-y-1">
          <Label htmlFor={`auth-uri-${authority.id}`}>{t("labels.externalUri")}</Label>
          <Input id={`auth-uri-${authority.id}`} value={uri} onChange={(e) => setUri(e.target.value)} />
        </div>
        <div className="flex gap-2">
          <Button type="button" size="sm" disabled={update.isPending}
            onClick={() => update.mutate(
              { id: authority.id, kind, external_uri: uri.trim() || null, labels },
              { onSuccess: () => setEditing(false) })}>
            {t("actions.save")}
          </Button>
          <Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
            {t("form.cancel")}
          </Button>
        </div>
      </li>
    );
  }

  return (
    <li className="flex items-center gap-2 border-b py-1 text-sm">
      <span className="flex-1">{labelText(authority.labels, lang)}</span>
      <Button type="button" variant="ghost" size="sm" onClick={() => setEditing(true)}>
        {t("actions.edit")}
      </Button>
      <DeleteConfirmDialog description={t("actions.confirmDeleteAuthority")}
        onConfirm={() => del.mutateAsync({ id: authority.id, kind })} />
    </li>
  );
}
```

- [ ] **Step 2: Use `AuthorityRow` in `AuthoritiesPage`** — replace the inline authority `<li>` mapping with `<AuthorityRow authority={a} kind={currentKind} lang={lang} />`. Add `import { AuthorityRow } from "./authority-row";`.

- [ ] **Step 3: Write a story** `web/src/authorities/authority-row.stories.tsx`:
```tsx
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect, userEvent } from 'storybook/test'

import { AuthorityRow } from './authority-row'

const meta = {
  component: AuthorityRow,
  tags: ['ai-generated'],
  args: {
    kind: 'person',
    lang: 'en',
    authority: { id: 'a1', kind: 'person', external_uri: null, labels: [{ lang: 'en', label: 'Astrid Lindgren' }] },
  },
} satisfies Meta<typeof AuthorityRow>

export default meta
type Story = StoryObj<typeof meta>

export const Display: Story = {
  play: async ({ canvas }) => {
    await expect(canvas.getByText('Astrid Lindgren')).toBeVisible()
  },
}

export const TogglesEdit: Story = {
  play: async ({ canvas }) => {
    await userEvent.click(canvas.getByRole('button', { name: 'Edit' }))
    await expect(canvas.getByRole('button', { name: 'Save' })).toBeVisible()
  },
}
```

- [ ] **Step 4: Run tests + typecheck + lint.**
```bash
cd web && pnpm test -- authority-row authorities && pnpm typecheck && pnpm lint
```
Expected: PASS. Update any existing authorities test asserting the old `<li>` markup.

- [ ] **Step 5: Commit.**
```bash
git add web/src/authorities
git commit -m "feat(web): edit/delete authorities in place (#30)"
```

---

## Task 11: Final verification

**Files:** none (verification only).

- [ ] **Step 1: Full backend suite + lint + fmt.**
```bash
cargo +nightly fmt --check
cargo clippy --workspace --all-targets -- -D warnings
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev \
MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace
```
Expected: all green.

- [ ] **Step 2: Full web suite.**
```bash
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build
```
Expected: all green; bundle ≤ 150 KB gz (check `pnpm check:size` if defined).

- [ ] **Step 3: en/sv i18n parity.** Run the existing parity test (it lives in the web suite) and confirm the new `actions.*` keys exist in both `en.json` and `sv.json` with identical structure.
```bash
cd web && pnpm test -- i18n
```
Expected: PASS.

- [ ] **Step 4: Codename scan.**
```bash
git grep -in 'biggus\|dickus' -- crates web/src | grep -v node_modules || echo "clean"
```
Expected: `clean`.

- [ ] **Step 5: Cargo.lock hygiene.** Confirm no dangling `Cargo.lock` change (no new deps were added in this milestone, but verify):
```bash
git status --short
```
Expected: clean working tree after all task commits.

- [ ] **Step 6: Manual smoke (optional but recommended).** With the stack up and `cargo run -p server` + `cd web && pnpm dev`, log in as an editor and verify: rename a vocabulary; edit a term and an authority; toggle a field's `required`; attempt to delete a term that an object uses → see the "used by N" message; clear the object's field → delete succeeds.

---

## Self-Review (completed)

**1. Spec coverage:**
- Endpoints — term PATCH/DELETE (T1), vocabulary PATCH/DELETE (T2), authority PATCH/DELETE (T3), field-def PATCH/DELETE (T4). ✓
- Block-with-409+count via JSONB scans — `count_objects_referencing_term/authority` (term/authority data_type-scoped `fields ->> key`), `count_objects_using_field` (`jsonb_exists`), vocab delete (terms + bindings count). ✓
- Field-def immutability — `UpdateFieldDefinitionRequest` exposes only `required`/`group`/`labels`; key/type/binding inputs disabled in the edit form. ✓
- `required` not retroactively enforced — update just sets the column; no object re-validation. ✓
- Vocabulary key rename allowed; unique collision → 409. ✓
- Audited — every db mutation records `Updated`/`Deleted` in-tx. ✓
- Frontend in-place edit + AlertDialog delete; 409 keeps dialog open with "used by N" (`DeleteConfirmDialog`). ✓
- Storybook — `DeleteConfirmDialog` (T7), `FieldForm` edit (T8), `TermRow` (T9), `AuthorityRow` (T10). ✓
- OpenAPI regenerated (T5); en/sv parity (T6, T11). ✓

**2. Placeholder scan:** Row-markup blocks in T8/T9/T1 are shown as commented JSX to splice into existing loops (the surrounding component code is unchanged and already in the repo); all new functions/handlers/components are complete. No "TBD"/"add error handling"/"similar to". The few "confirm the variant name" notes (e.g. `FieldType::Integer`) are verification steps, not deferred implementation.

**3. Type consistency:** `DeleteOutcome { Deleted, InUse { count: i64 }, NotFound }` defined once (T1) and matched in every handler. `InUseView { count: i64 }` defined once in `admin_vocab.rs` and imported by `admin_authorities.rs`/`admin_objects.rs`. Hook input shapes match the handler path params and request bodies (`{vocabularyId, termId, ...}`, `{id, kind, ...}`, `{key, required, group, labels}`). `InUseError(count: number)` parsed from the 409 JSON `{count}` in every delete hook and consumed by `DeleteConfirmDialog`.

## Notes
- No new crates/deps → no `Cargo.lock` churn expected; if any appears, stage it in the same commit (lesson from a prior milestone).
- `actor(&auth.user)` exists only in `admin_objects.rs`; `admin_vocab.rs`/`admin_authorities.rs` inline `AuditActor::User(auth.user.id.to_uuid())` (existing convention) — both are used as-is.
- Field-definition *create* remains unaudited (pre-existing gap, out of scope); this milestone audits only the new update/delete paths.
