From ed608c6e372342b688a0effead0d5fa39d34ad28 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 09:12:41 +0200 Subject: [PATCH] docs: add Plan 3 (Catalogue core) implementation plan Typed inventory-minimum object record + CRUD; first consumer of the audit spine (create/update/delete record audit entries with field-level diffs in the write tx). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/plans/2026-06-02-catalogue-core.md | 715 ++++++++++++++++++++++++ 1 file changed, 715 insertions(+) create mode 100644 docs/plans/2026-06-02-catalogue-core.md diff --git a/docs/plans/2026-06-02-catalogue-core.md b/docs/plans/2026-06-02-catalogue-core.md new file mode 100644 index 0000000..35ad423 --- /dev/null +++ b/docs/plans/2026-06-02-catalogue-core.md @@ -0,0 +1,715 @@ +# 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 { + 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, + pub current_location: Option, + pub current_owner: Option, + pub recorder: Option, + pub recording_date: Option, + 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, + pub current_location: Option, + pub current_owner: Option, + pub recorder: Option, + pub recording_date: Option, + 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 = + 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= 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= 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 { + 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, 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, 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 { + 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)> { + 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 { + 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 { + 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= cargo test -p db --test catalog` → PASS (3 tests). + +- [ ] **Step 6: Lint.** `cargo +nightly fmt`; `DATABASE_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= 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 { + 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 { + 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= cargo test -p db --test catalog_mutations` → PASS (4 tests). + +- [ ] **Step 5: Full workspace check.** +```bash +cargo +nightly fmt --check +DATABASE_URL= cargo clippy --workspace --all-targets -- -D warnings +DATABASE_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. `` 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.