# Catalogue Core 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:** The typed inventory-minimum catalogue object (Approach C's strongly-typed core, §6.1) with CRUD, and — crucially — the **first real consumer of the audit spine**: every create/update/delete records an audit entry (field-level diffs on update) inside the write transaction.

**Architecture:** `domain` holds `ObjectId`, `Visibility`, `ObjectInput` (mutable fields) and `CatalogueObject` (read model). `db::catalog` owns the `object` table (migration 0003) and the repository. Writes take `&mut PgConnection` and record audit via `db::audit::record` on the same connection, so the change and its audit entry commit atomically. Vocabulary/authority *binding* of fields is deferred to the flexible layer (Plan 4); fields are simple types here. No HTTP, no flexible fields, no search.

**Tech Stack:** Rust 2024, sqlx 0.8 (`time`+`json`), `time::Date`/`OffsetDateTime`, `serde_json` (now a normal dep of `db`, to build audit `FieldChange` values). Tests use `#[sqlx::test]`.

## Design decisions (approved)
- One `object` table = object **or group** (`number_of_objects ≥ 1`).
- Inventory-minimum fields as **simple types** (free text); vocab/authority binding deferred to Plan 4.
- **Audit on every write**, in the write transaction (this plan is the audit spine's first consumer).
- `Visibility` stored now; publish/unpublish transitions + `PublicView` + public API in Plan 7.
- Scope: object CRUD + list in `domain` + `db`.

## Prerequisites
- Postgres for tests with CREATE DATABASE rights; pass `DATABASE_URL` inline (e.g. `postgres://postgres:postgres@localhost:5433/cms_dev`). Shell env does not persist between commands.

## File Structure
```
crates/domain/
  src/id.rs            + ObjectId via id_newtype!
  src/object.rs        Visibility, ObjectInput, CatalogueObject (+ to_input)
  src/lib.rs           re-exports
crates/db/
  Cargo.toml           serde_json -> normal dependency
  migrations/0003_object.sql
  src/catalog.rs       create/get/list/update/delete + audit integration
  src/lib.rs           pub mod catalog;
  tests/catalog.rs
```

---

## Task 1: `domain` — object types

**Files:** modify `crates/domain/src/id.rs`, `crates/domain/src/lib.rs`; create `crates/domain/src/object.rs`.

- [ ] **Step 1: Add `ObjectId`** to `crates/domain/src/id.rs` — add another `id_newtype!` invocation after the existing ones:
```rust
id_newtype!(
    /// Identifier for a catalogue object (or group of objects).
    ObjectId
);
```

- [ ] **Step 2: Create `crates/domain/src/object.rs`:**
```rust
use serde::{Deserialize, Serialize};
use time::{Date, OffsetDateTime};

use crate::ObjectId;

/// Publication state of a catalogue record.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Visibility {
    /// Work in progress; not shown anywhere public.
    #[default]
    Draft,
    /// Complete but internal-only.
    Internal,
    /// Published; eligible for the public API.
    Public,
}

impl Visibility {
    pub fn as_str(&self) -> &'static str {
        match self {
            Visibility::Draft => "draft",
            Visibility::Internal => "internal",
            Visibility::Public => "public",
        }
    }

    pub fn from_db(s: &str) -> Option<Self> {
        match s {
            "draft" => Some(Visibility::Draft),
            "internal" => Some(Visibility::Internal),
            "public" => Some(Visibility::Public),
            _ => None,
        }
    }
}

/// The mutable inventory-minimum fields of a catalogue object.
#[derive(Debug, Clone, PartialEq)]
pub struct ObjectInput {
    pub object_number: String,
    pub object_name: String,
    pub number_of_objects: i32,
    pub brief_description: Option<String>,
    pub current_location: Option<String>,
    pub current_owner: Option<String>,
    pub recorder: Option<String>,
    pub recording_date: Option<Date>,
    pub visibility: Visibility,
}

/// A catalogue object (or group of objects), read back from storage.
#[derive(Debug, Clone, PartialEq)]
pub struct CatalogueObject {
    pub id: ObjectId,
    pub object_number: String,
    pub object_name: String,
    pub number_of_objects: i32,
    pub brief_description: Option<String>,
    pub current_location: Option<String>,
    pub current_owner: Option<String>,
    pub recorder: Option<String>,
    pub recording_date: Option<Date>,
    pub visibility: Visibility,
    pub created_at: OffsetDateTime,
    pub updated_at: OffsetDateTime,
}

impl CatalogueObject {
    /// The mutable fields as an [`ObjectInput`] (used to diff against an update).
    pub fn to_input(&self) -> ObjectInput {
        ObjectInput {
            object_number: self.object_number.clone(),
            object_name: self.object_name.clone(),
            number_of_objects: self.number_of_objects,
            brief_description: self.brief_description.clone(),
            current_location: self.current_location.clone(),
            current_owner: self.current_owner.clone(),
            recorder: self.recorder.clone(),
            recording_date: self.recording_date,
            visibility: self.visibility,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn visibility_round_trips_and_defaults_to_draft() {
        for v in [Visibility::Draft, Visibility::Internal, Visibility::Public] {
            assert_eq!(Visibility::from_db(v.as_str()), Some(v));
        }
        assert_eq!(Visibility::from_db("secret"), None);
        assert_eq!(Visibility::default(), Visibility::Draft);
    }
}
```

- [ ] **Step 3: Update `crates/domain/src/lib.rs`** — add `mod object;` (alphabetical, after `mod label;` / before `mod vocabulary;` is fine) and add `ObjectId` to the id re-export and the object types. The id re-export becomes:
```rust
pub use id::{AuthorityId, ObjectId, OrgId, TermId, VocabularyId};
```
and add:
```rust
pub use object::{CatalogueObject, ObjectInput, Visibility};
```

- [ ] **Step 4: Test + lint.** `cargo test -p domain` → all pass (incl. the new visibility test). `cargo +nightly fmt`; `cargo clippy -p domain --all-targets -- -D warnings` → clean.

- [ ] **Step 5: Commit.**
```bash
git add crates/domain
git commit -m "feat(domain): add catalogue object types (Visibility, ObjectInput, CatalogueObject)"
```

---

## Task 2: `db` migration — object table

**Files:** create `crates/db/migrations/0003_object.sql`; modify `crates/db/tests/migrate.rs`.

- [ ] **Step 1: Create `crates/db/migrations/0003_object.sql`:**
```sql
-- Catalogue objects (the inventory-minimum core). One row = one object or a group.
CREATE TABLE object (
    id                UUID PRIMARY KEY,
    object_number     TEXT NOT NULL UNIQUE,
    object_name       TEXT NOT NULL,
    number_of_objects INTEGER NOT NULL DEFAULT 1 CHECK (number_of_objects >= 1),
    brief_description TEXT,
    current_location  TEXT,
    current_owner     TEXT,
    recorder          TEXT,
    recording_date    DATE,
    visibility        TEXT NOT NULL DEFAULT 'draft'
                          CHECK (visibility IN ('draft', 'internal', 'public')),
    created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at        TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX object_visibility_idx ON object (visibility);
```

- [ ] **Step 2: Extend `crates/db/tests/migrate.rs`** — append:
```rust
#[sqlx::test]
async fn migrate_creates_object_table(pool: PgPool) {
    let db = Db::from_pool(pool);
    let regclass: Option<String> =
        sqlx::query_scalar("SELECT to_regclass('public.object')::text")
            .fetch_one(db.pool())
            .await
            .unwrap();
    assert_eq!(regclass.as_deref(), Some("object"));
}
```

- [ ] **Step 3: Run + lint.** `DATABASE_URL=<url> cargo test -p db --test migrate` → 3 tests pass. `cargo +nightly fmt`; clippy clean.

- [ ] **Step 4: Commit.**
```bash
git add crates/db/migrations crates/db/tests/migrate.rs
git commit -m "feat(db): add object table"
```

---

## Task 3: `db::catalog` — create, read, list (with audit on create)

**Files:** modify `crates/db/Cargo.toml`, `crates/db/src/lib.rs`; create `crates/db/src/catalog.rs`, `crates/db/tests/catalog.rs`.

- [ ] **Step 1: Make `serde_json` a normal dependency of `db`.** In `crates/db/Cargo.toml`, move `serde_json` from `[dev-dependencies]` to `[dependencies]` (it is needed in `catalog.rs` to build audit `FieldChange` values). Result:
```toml
[dependencies]
sqlx.workspace = true
thiserror.workspace = true
domain = { path = "../domain" }
uuid.workspace = true
time.workspace = true
serde_json.workspace = true

[dev-dependencies]
tokio.workspace = true
```

- [ ] **Step 2: Write the failing test** `crates/db/tests/catalog.rs`:
```rust
use db::{Db, audit, catalog};
use domain::{AuditAction, AuditActor, ObjectInput, Visibility};
use sqlx::PgPool;

fn sample_input(number: &str) -> ObjectInput {
    ObjectInput {
        object_number: number.into(),
        object_name: "vase".into(),
        number_of_objects: 1,
        brief_description: Some("a small vase".into()),
        current_location: Some("shelf A1".into()),
        current_owner: None,
        recorder: Some("anna".into()),
        recording_date: None,
        visibility: Visibility::Draft,
    }
}

#[sqlx::test]
async fn create_reads_back_and_audits(pool: PgPool) {
    let db = Db::from_pool(pool);

    let mut tx = db.pool().begin().await.unwrap();
    let id = catalog::create_object(&mut *tx, AuditActor::System, &sample_input("LM-1"))
        .await
        .unwrap();
    tx.commit().await.unwrap();

    let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
    assert_eq!(obj.object_number, "LM-1");
    assert_eq!(obj.object_name, "vase");
    assert_eq!(obj.number_of_objects, 1);
    assert_eq!(obj.brief_description.as_deref(), Some("a small vase"));
    assert_eq!(obj.visibility, Visibility::Draft);

    // The create was audited within the same transaction.
    let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap();
    assert_eq!(history.len(), 1);
    assert_eq!(history[0].action, AuditAction::Created);
    assert_eq!(history[0].actor, AuditActor::System);
    assert!(history[0].changes.iter().any(|c| c.field == "object_number"));
}

#[sqlx::test]
async fn list_returns_created_objects(pool: PgPool) {
    let db = Db::from_pool(pool);
    let mut tx = db.pool().begin().await.unwrap();
    catalog::create_object(&mut *tx, AuditActor::System, &sample_input("LM-1")).await.unwrap();
    catalog::create_object(&mut *tx, AuditActor::System, &sample_input("LM-2")).await.unwrap();
    tx.commit().await.unwrap();

    let all = catalog::list_objects(db.pool()).await.unwrap();
    assert_eq!(all.len(), 2);
    assert_eq!(all[0].object_number, "LM-1");
    assert_eq!(all[1].object_number, "LM-2");
}

#[sqlx::test]
async fn object_by_id_missing_is_none(pool: PgPool) {
    let db = Db::from_pool(pool);
    assert!(catalog::object_by_id(db.pool(), domain::ObjectId::new()).await.unwrap().is_none());
}
```

- [ ] **Step 3: Run to verify it fails.** `DATABASE_URL=<url> cargo test -p db --test catalog` → FAIL.

- [ ] **Step 4: Implement** `crates/db/src/catalog.rs`:
```rust
//! Catalogue objects (the inventory-minimum core). Writes record audit entries
//! in the caller's transaction.

use domain::{
    AuditAction, AuditActor, CatalogueObject, FieldChange, NewAuditEvent, ObjectId, ObjectInput,
    Visibility,
};
use serde_json::{Value, json};
use sqlx::Row;

use crate::audit;

/// The entity_type recorded in the audit log for catalogue objects.
const ENTITY_TYPE: &str = "object";

/// Create an object and record a `created` audit entry, both on `conn`
/// (pass a transaction connection `&mut *tx` so they commit atomically).
pub async fn create_object(
    conn: &mut sqlx::PgConnection,
    actor: AuditActor,
    input: &ObjectInput,
) -> Result<ObjectId, sqlx::Error> {
    let id = ObjectId::new();
    sqlx::query(
        "INSERT INTO object \
         (id, object_number, object_name, number_of_objects, brief_description, \
          current_location, current_owner, recorder, recording_date, visibility) \
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
    )
    .bind(id.to_uuid())
    .bind(&input.object_number)
    .bind(&input.object_name)
    .bind(input.number_of_objects)
    .bind(input.brief_description.as_deref())
    .bind(input.current_location.as_deref())
    .bind(input.current_owner.as_deref())
    .bind(input.recorder.as_deref())
    .bind(input.recording_date)
    .bind(input.visibility.as_str())
    .execute(&mut *conn)
    .await?;

    let changes = creation_changes(input);
    audit::record(
        &mut *conn,
        &NewAuditEvent {
            actor,
            action: AuditAction::Created,
            entity_type: ENTITY_TYPE.to_owned(),
            entity_id: id.to_uuid(),
            changes,
        },
    )
    .await?;

    Ok(id)
}

/// Fetch one object by id.
pub async fn object_by_id<'e, E>(
    executor: E,
    id: ObjectId,
) -> Result<Option<CatalogueObject>, sqlx::Error>
where
    E: sqlx::PgExecutor<'e>,
{
    let row = sqlx::query(SELECT_OBJECT_BY_ID)
        .bind(id.to_uuid())
        .fetch_optional(executor)
        .await?;
    row.map(map_object).transpose()
}

/// List all objects, ordered by object number.
pub async fn list_objects<'e, E>(executor: E) -> Result<Vec<CatalogueObject>, sqlx::Error>
where
    E: sqlx::PgExecutor<'e>,
{
    // TODO: add LIMIT/keyset pagination before exposing this via the API.
    let rows = sqlx::query(SELECT_OBJECTS_ORDERED)
        .fetch_all(executor)
        .await?;
    rows.into_iter().map(map_object).collect()
}

const OBJECT_COLUMNS: &str = "id, object_number, object_name, number_of_objects, \
     brief_description, current_location, current_owner, recorder, recording_date, \
     visibility, created_at, updated_at";

const SELECT_OBJECT_BY_ID: &str =
    "SELECT id, object_number, object_name, number_of_objects, brief_description, \
     current_location, current_owner, recorder, recording_date, visibility, \
     created_at, updated_at FROM object WHERE id = $1";

const SELECT_OBJECTS_ORDERED: &str =
    "SELECT id, object_number, object_name, number_of_objects, brief_description, \
     current_location, current_owner, recorder, recording_date, visibility, \
     created_at, updated_at FROM object ORDER BY object_number";

fn map_object(row: sqlx::postgres::PgRow) -> Result<CatalogueObject, sqlx::Error> {
    let visibility_str: String = row.try_get("visibility")?;
    let visibility = Visibility::from_db(&visibility_str)
        .ok_or_else(|| sqlx::Error::Decode(format!("unknown visibility: {visibility_str}").into()))?;
    Ok(CatalogueObject {
        id: ObjectId::from_uuid(row.try_get("id")?),
        object_number: row.try_get("object_number")?,
        object_name: row.try_get("object_name")?,
        number_of_objects: row.try_get("number_of_objects")?,
        brief_description: row.try_get("brief_description")?,
        current_location: row.try_get("current_location")?,
        current_owner: row.try_get("current_owner")?,
        recorder: row.try_get("recorder")?,
        recording_date: row.try_get("recording_date")?,
        visibility,
        created_at: row.try_get("created_at")?,
        updated_at: row.try_get("updated_at")?,
    })
}

/// The mutable fields as `(name, value)` pairs, for building audit diffs.
/// `None` means the field is unset (NULL).
fn field_values(input: &ObjectInput) -> Vec<(&'static str, Option<Value>)> {
    vec![
        ("object_number", Some(json!(input.object_number))),
        ("object_name", Some(json!(input.object_name))),
        ("number_of_objects", Some(json!(input.number_of_objects))),
        ("brief_description", input.brief_description.as_ref().map(|v| json!(v))),
        ("current_location", input.current_location.as_ref().map(|v| json!(v))),
        ("current_owner", input.current_owner.as_ref().map(|v| json!(v))),
        ("recorder", input.recorder.as_ref().map(|v| json!(v))),
        (
            "recording_date",
            input.recording_date.and_then(|d| serde_json::to_value(d).ok()),
        ),
        ("visibility", Some(json!(input.visibility.as_str()))),
    ]
}

/// Audit changes for a newly created object: every set field as an `after` value.
fn creation_changes(input: &ObjectInput) -> Vec<FieldChange> {
    field_values(input)
        .into_iter()
        .filter_map(|(field, after)| {
            after.map(|a| FieldChange {
                field: field.to_owned(),
                before: None,
                after: Some(a),
            })
        })
        .collect()
}

/// Audit changes between two field sets: only the fields whose value changed.
fn update_changes(old: &ObjectInput, new: &ObjectInput) -> Vec<FieldChange> {
    field_values(old)
        .into_iter()
        .zip(field_values(new))
        .filter_map(|((field, before), (_, after))| {
            if before != after {
                Some(FieldChange { field: field.to_owned(), before, after })
            } else {
                None
            }
        })
        .collect()
}
```
Note: `OBJECT_COLUMNS` is intentionally unused for now (kept adjacent for documentation of the column set); if clippy flags it as dead code, DELETE the `OBJECT_COLUMNS` const (the two SELECT consts are the live ones). `update_changes` is used in Task 4 — if clippy flags it unused in this task, add `#[allow(dead_code)]` to `update_changes` with a `// used in Task 4 (update_object)` comment, OR implement Task 4 immediately after so it's used. (Recommended: proceed to Task 4 before the final clippy gate.)

Add to `crates/db/src/lib.rs` (top-level): `pub mod catalog;`

- [ ] **Step 5: Run to verify it passes.** `DATABASE_URL=<url> cargo test -p db --test catalog` → PASS (3 tests).

- [ ] **Step 6: Lint.** `cargo +nightly fmt`; `DATABASE_URL=<url> cargo clippy -p db --all-targets -- -D warnings` → clean (resolve the `OBJECT_COLUMNS`/`update_changes` dead-code note as above).

- [ ] **Step 7: Commit.**
```bash
git add crates/db
git commit -m "feat(db): add catalogue object create/read/list with audit on create"
```

---

## Task 4: `db::catalog` — update & delete (with audit diffs)

**Files:** modify `crates/db/src/catalog.rs`; test `crates/db/tests/catalog_mutations.rs`.

- [ ] **Step 1: Write the failing test** `crates/db/tests/catalog_mutations.rs`:
```rust
use db::{Db, audit, catalog};
use domain::{AuditAction, AuditActor, ObjectInput, Visibility};
use sqlx::PgPool;

fn base() -> ObjectInput {
    ObjectInput {
        object_number: "LM-1".into(),
        object_name: "vase".into(),
        number_of_objects: 1,
        brief_description: None,
        current_location: Some("shelf A1".into()),
        current_owner: None,
        recorder: None,
        recording_date: None,
        visibility: Visibility::Draft,
    }
}

#[sqlx::test]
async fn update_changes_are_audited_as_diffs(pool: PgPool) {
    let db = Db::from_pool(pool);

    let mut tx = db.pool().begin().await.unwrap();
    let id = catalog::create_object(&mut *tx, AuditActor::System, &base()).await.unwrap();
    tx.commit().await.unwrap();

    let mut changed = base();
    changed.object_name = "roman vase".into();
    changed.visibility = Visibility::Public;

    let mut tx = db.pool().begin().await.unwrap();
    let updated = catalog::update_object(&mut *tx, AuditActor::System, id, &changed).await.unwrap();
    tx.commit().await.unwrap();
    assert!(updated);

    let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
    assert_eq!(obj.object_name, "roman vase");
    assert_eq!(obj.visibility, Visibility::Public);

    let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap();
    assert_eq!(history.len(), 2); // created + updated
    let update = &history[1];
    assert_eq!(update.action, AuditAction::Updated);
    // Exactly the two changed fields are recorded.
    let mut fields: Vec<&str> = update.changes.iter().map(|c| c.field.as_str()).collect();
    fields.sort_unstable();
    assert_eq!(fields, vec!["object_name", "visibility"]);
}

#[sqlx::test]
async fn no_op_update_records_no_audit(pool: PgPool) {
    let db = Db::from_pool(pool);
    let mut tx = db.pool().begin().await.unwrap();
    let id = catalog::create_object(&mut *tx, AuditActor::System, &base()).await.unwrap();
    tx.commit().await.unwrap();

    let mut tx = db.pool().begin().await.unwrap();
    let updated = catalog::update_object(&mut *tx, AuditActor::System, id, &base()).await.unwrap();
    tx.commit().await.unwrap();
    assert!(updated);

    let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap();
    assert_eq!(history.len(), 1, "a no-op update must not add an audit entry");
}

#[sqlx::test]
async fn update_missing_returns_false(pool: PgPool) {
    let db = Db::from_pool(pool);
    let mut tx = db.pool().begin().await.unwrap();
    let updated = catalog::update_object(&mut *tx, AuditActor::System, domain::ObjectId::new(), &base())
        .await
        .unwrap();
    tx.commit().await.unwrap();
    assert!(!updated);
}

#[sqlx::test]
async fn delete_removes_and_audits(pool: PgPool) {
    let db = Db::from_pool(pool);
    let mut tx = db.pool().begin().await.unwrap();
    let id = catalog::create_object(&mut *tx, AuditActor::System, &base()).await.unwrap();
    tx.commit().await.unwrap();

    let mut tx = db.pool().begin().await.unwrap();
    let deleted = catalog::delete_object(&mut *tx, AuditActor::System, id).await.unwrap();
    tx.commit().await.unwrap();
    assert!(deleted);

    assert!(catalog::object_by_id(db.pool(), id).await.unwrap().is_none());
    let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap();
    assert_eq!(history.last().unwrap().action, AuditAction::Deleted);
}
```

- [ ] **Step 2: Run to verify it fails.** `DATABASE_URL=<url> cargo test -p db --test catalog_mutations` → FAIL (`update_object`/`delete_object` missing).

- [ ] **Step 3: Implement** — append to `crates/db/src/catalog.rs`:
```rust
/// Update an object and record an `updated` audit entry with field-level diffs,
/// both on `conn`. Returns `false` if the object does not exist. A no-op update
/// (no fields changed) records no audit entry.
pub async fn update_object(
    conn: &mut sqlx::PgConnection,
    actor: AuditActor,
    id: ObjectId,
    input: &ObjectInput,
) -> Result<bool, sqlx::Error> {
    let Some(old) = object_by_id(&mut *conn, id).await? else {
        return Ok(false);
    };

    sqlx::query(
        "UPDATE object SET \
         object_number = $2, object_name = $3, number_of_objects = $4, \
         brief_description = $5, current_location = $6, current_owner = $7, \
         recorder = $8, recording_date = $9, visibility = $10, updated_at = now() \
         WHERE id = $1",
    )
    .bind(id.to_uuid())
    .bind(&input.object_number)
    .bind(&input.object_name)
    .bind(input.number_of_objects)
    .bind(input.brief_description.as_deref())
    .bind(input.current_location.as_deref())
    .bind(input.current_owner.as_deref())
    .bind(input.recorder.as_deref())
    .bind(input.recording_date)
    .bind(input.visibility.as_str())
    .execute(&mut *conn)
    .await?;

    let changes = update_changes(&old.to_input(), input);
    if !changes.is_empty() {
        audit::record(
            &mut *conn,
            &NewAuditEvent {
                actor,
                action: AuditAction::Updated,
                entity_type: ENTITY_TYPE.to_owned(),
                entity_id: id.to_uuid(),
                changes,
            },
        )
        .await?;
    }

    Ok(true)
}

/// Delete an object and record a `deleted` audit entry, both on `conn`.
/// Returns `false` if the object did not exist.
pub async fn delete_object(
    conn: &mut sqlx::PgConnection,
    actor: AuditActor,
    id: ObjectId,
) -> Result<bool, sqlx::Error> {
    if object_by_id(&mut *conn, id).await?.is_none() {
        return Ok(false);
    }

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

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

    Ok(true)
}
```
If you added a temporary `#[allow(dead_code)]` to `update_changes` in Task 3, remove it now (it is used here). If `OBJECT_COLUMNS` is unused, delete that const.

- [ ] **Step 4: Run to verify it passes.** `DATABASE_URL=<url> cargo test -p db --test catalog_mutations` → PASS (4 tests).

- [ ] **Step 5: Full workspace check.**
```bash
cargo +nightly fmt --check
DATABASE_URL=<url> cargo clippy --workspace --all-targets -- -D warnings
DATABASE_URL=<url> cargo test --workspace
```
Expected: all green.

- [ ] **Step 6: Commit.**
```bash
git add crates/db
git commit -m "feat(db): add catalogue object update/delete with audited field diffs"
```

---

## Self-Review (completed)

**Spec coverage (§6.1 typed core + audit integration):**
- Inventory-minimum object (object/group via number_of_objects), simple typed fields → Tasks 1–2. ✓
- CRUD + list → Tasks 3–4. ✓
- Audit on create/update/delete inside the write transaction, field-level diffs on update → Task 3 (create) + Task 4 (update/delete), verified via `audit::history_for` in tests. ✓
- Visibility stored; transitions/PublicView/public API deferred to Plan 7. ✓ (intentional)
- Vocab/authority binding deferred to Plan 4. ✓ (intentional)
- SQL confined to `db`; `domain` I/O-free. ✓

**Placeholder scan:** none. `<url>` is the documented `DATABASE_URL`. The `OBJECT_COLUMNS`/`update_changes` dead-code notes are explicit resolution instructions, not placeholders.

**Type consistency:** `ObjectInput`/`CatalogueObject` field names/types identical across `domain` (Task 1), the repo (Tasks 3–4), and tests. `create_object`/`update_object`/`delete_object` take `(&mut PgConnection, AuditActor, …)`; reads take `impl PgExecutor`. `field_values`/`creation_changes`/`update_changes` operate on the same nine mutable fields; `to_input` (domain) bridges `CatalogueObject` → `ObjectInput` for diffing. `ENTITY_TYPE = "object"` matches the `"object"` literal used in tests' `history_for` calls.

## Notes for follow-on plans
- The audit `actor` is threaded as a parameter; the API layer (Plan 7+) will pass the authenticated user (Plan 9 introduces `UserId`; until then `AuditActor::System` or a raw user uuid).
- `list_objects` is unpaginated (TODO in code) — add keyset pagination before the API exposes it (same as `audit::history_for`, `vocab::list_terms`, `authority::list_by_kind`).
- Flexible fields (Plan 4) attach to this object via the field-definition registry + JSONB; vocab/authority-bound fields (object_name as TermRef, owner as AuthorityRef) live there.
- `PublicView` projection + visibility transition methods + public read API: Plan 7.
