# Publishing: Visibility Transitions, PublicView & Public Read API 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:** Turn the publishing pillar on: a type-driven `Visibility` state machine (stepwise `draft↔internal↔public`), an audited `db` transition + public-only reads, and the first real domain HTTP surface — an unauthenticated, read-only **public API** (`/api/public/objects`) that serves only `public` records as a leak-proof `PublicView` projection. **Architecture:** Three layers, each testable in isolation (no auth needed — the public surface is unauthenticated by definition; the admin HTTP endpoint that *triggers* transitions waits for the auth phase, same "build capability now, wire surface later" pattern used for search). - `domain` — `Visibility::transition_to` / `can_transition_to` + an `IllegalTransition` error (the state machine). - `db` — `set_visibility` (validates via the domain machine, reuses `update_object`'s diff/audit path) + `public_object_by_id` / `list_public_objects` / `count_public_objects` (filter `visibility = 'public'` in SQL). - `api` — a `PublicView` response DTO (carries only public-safe fields, so leaking an internal field is structurally impossible) + `/api/public/objects` (paginated list) and `/api/public/objects/{id}` (404 for missing **or** non-public, so non-public existence isn't revealed), registered in the OpenAPI doc. **Tech Stack:** Rust 2024, axum 0.8, sqlx 0.8, utoipa 5, serde, thiserror. Tests: `#[sqlx::test]` (db) and axum `oneshot` over `#[sqlx::test]` (api). ## Design decisions (approved) - **PublicView is core-only for MVP:** `id`, `object_number`, `object_name`, `brief_description`. **No flexible fields, no location/owner/recorder/dates.** Per-field publishability (which would let flexible fields surface selectively) is post-MVP; until then the projection type simply lacks the unsafe fields. - **Stepwise transitions:** legal single steps are `draft↔internal` and `internal↔public` only. `draft→public` (and `public→draft`) in one jump is illegal. Setting visibility to its current value is an idempotent no-op (`Ok`). - **Transitions land in `domain` + `db` only** this phase. The admin HTTP endpoint to invoke them arrives with auth (later phase). - **Public-facing search is post-MVP** (arch spec §12) — this plan adds no public search endpoint; public list is a `db` query. - **404, not 403,** for a non-public record on the public surface (don't leak existence). ## Prerequisites - Postgres for tests; pass `DATABASE_URL` inline. Pass transaction connections as `&mut tx` (NOT `&mut *tx`). - `cargo +nightly fmt` (nightly). `cargo clippy --all-targets -- -D warnings` must stay clean. - The codename "biggus"/"dickus" must appear nowhere in code/comments/identifiers. ## File Structure ``` crates/domain/src/object.rs + IllegalTransition, Visibility::{can_transition_to, transition_to}, tests crates/domain/src/lib.rs + export IllegalTransition crates/db/src/catalog.rs + VisibilityError, set_visibility, public_object_by_id, list_public_objects, count_public_objects crates/db/tests/visibility.rs (new) transition rules + audit + public-read filtering crates/api/Cargo.toml + domain, uuid deps crates/api/src/public.rs (new) PublicView, Pagination, PublicObjectPage, handlers, routes crates/api/src/lib.rs + mod public; merge public::routes() crates/api/src/openapi.rs + register public paths + schemas crates/api/tests/public.rs (new) list/get handler tests (incl. leak + 404 assertions) ``` --- ## Task 1: `domain` — `Visibility` state machine **Files:** modify `crates/domain/src/object.rs`, `crates/domain/src/lib.rs`. - [ ] **Step 1: Write the failing tests.** Add to the `#[cfg(test)] mod tests` in `crates/domain/src/object.rs`: ```rust #[test] fn stepwise_transitions_are_legal() { use Visibility::*; assert_eq!(Draft.transition_to(Internal), Ok(Internal)); assert_eq!(Internal.transition_to(Public), Ok(Public)); assert_eq!(Public.transition_to(Internal), Ok(Internal)); assert_eq!(Internal.transition_to(Draft), Ok(Draft)); } #[test] fn skipping_a_step_is_illegal() { use Visibility::*; assert_eq!( Draft.transition_to(Public), Err(IllegalTransition { from: Draft, to: Public }) ); assert_eq!( Public.transition_to(Draft), Err(IllegalTransition { from: Public, to: Draft }) ); } #[test] fn setting_to_current_value_is_a_noop_ok() { for v in [Visibility::Draft, Visibility::Internal, Visibility::Public] { assert_eq!(v.transition_to(v), Ok(v)); } } ``` - [ ] **Step 2: Run to verify it fails.** `cargo test -p domain` → FAIL (`transition_to` / `IllegalTransition` missing). - [ ] **Step 3: Implement.** In `crates/domain/src/object.rs`, after the `impl Visibility` block (the existing one with `as_str`/`from_db`), add the transition API and the error type. (domain has no `thiserror` dependency — implement `Display`/`Error` by hand to keep the core dependency-free.) ```rust impl Visibility { /// Whether `self` may move directly to `target`. Legal single steps are /// `draft↔internal` and `internal↔public`; `draft↔public` is not one step. pub fn can_transition_to(self, target: Visibility) -> bool { use Visibility::*; matches!( (self, target), (Draft, Internal) | (Internal, Draft) | (Internal, Public) | (Public, Internal) ) } /// Validate a stepwise transition to `target`. Setting to the current value is an /// idempotent no-op (`Ok`). A forbidden jump returns [`IllegalTransition`]. pub fn transition_to(self, target: Visibility) -> Result { if self == target || self.can_transition_to(target) { Ok(target) } else { Err(IllegalTransition { from: self, to: target }) } } } /// An attempted visibility change the state machine forbids. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct IllegalTransition { pub from: Visibility, pub to: Visibility, } impl std::fmt::Display for IllegalTransition { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "illegal visibility transition: {} -> {}", self.from.as_str(), self.to.as_str() ) } } impl std::error::Error for IllegalTransition {} ``` In `crates/domain/src/lib.rs`, extend the object re-export: ```rust pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility}; ``` - [ ] **Step 4: Run to verify it passes.** `cargo test -p domain` → PASS. - [ ] **Step 5: Lint.** `cargo +nightly fmt`; `cargo clippy -p domain --all-targets -- -D warnings` → clean. - [ ] **Step 6: Commit.** ```bash git add crates/domain git commit -m "feat(domain): stepwise Visibility state machine (transition_to + IllegalTransition)" ``` --- ## Task 2: `db` — audited visibility transition + public reads **Files:** modify `crates/db/src/catalog.rs`; create `crates/db/tests/visibility.rs`. - [ ] **Step 1: Write the failing tests** `crates/db/tests/visibility.rs`: ```rust use db::{Db, audit, catalog}; use domain::{AuditAction, AuditActor, IllegalTransition, ObjectInput, Visibility}; use sqlx::PgPool; fn object(number: &str, visibility: Visibility) -> ObjectInput { ObjectInput { object_number: number.into(), object_name: "vase".into(), number_of_objects: 1, brief_description: None, current_location: None, current_owner: None, recorder: None, recording_date: None, visibility, } } #[sqlx::test] async fn publish_steps_through_internal_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, &object("LM-1", Visibility::Draft)) .await .unwrap(); catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Internal) .await .unwrap(); catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public) .await .unwrap(); tx.commit().await.unwrap(); let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap(); assert_eq!(obj.visibility, Visibility::Public); // created + two visibility updates let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap(); assert_eq!(history.len(), 3); assert_eq!(history[2].action, AuditAction::Updated); let changed: Vec<&str> = history[2].changes.iter().map(|c| c.field.as_str()).collect(); assert_eq!(changed, vec!["visibility"]); } #[sqlx::test] async fn skipping_a_step_is_rejected_and_unchanged(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, &object("LM-1", Visibility::Draft)) .await .unwrap(); tx.commit().await.unwrap(); let mut tx = db.pool().begin().await.unwrap(); let err = catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public) .await .unwrap_err(); tx.commit().await.unwrap(); assert!(matches!( err, catalog::VisibilityError::Illegal(IllegalTransition { from: Visibility::Draft, to: Visibility::Public }) )); let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap(); assert_eq!(obj.visibility, Visibility::Draft); // unchanged } #[sqlx::test] async fn set_visibility_on_missing_object_errors(pool: PgPool) { let db = Db::from_pool(pool); let mut tx = db.pool().begin().await.unwrap(); let err = catalog::set_visibility( &mut tx, AuditActor::System, domain::ObjectId::new(), Visibility::Internal, ) .await .unwrap_err(); tx.commit().await.unwrap(); assert!(matches!(err, catalog::VisibilityError::ObjectNotFound)); } #[sqlx::test] async fn no_op_set_to_current_visibility_writes_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, &object("LM-1", Visibility::Draft)) .await .unwrap(); catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Draft) .await .unwrap(); tx.commit().await.unwrap(); let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap(); assert_eq!(history.len(), 1); // only `created`; the no-op transition recorded nothing } #[sqlx::test] async fn public_reads_return_only_public_records(pool: PgPool) { let db = Db::from_pool(pool); let mut tx = db.pool().begin().await.unwrap(); let draft = catalog::create_object(&mut tx, AuditActor::System, &object("D-1", Visibility::Draft)) .await .unwrap(); let pub_id = catalog::create_object(&mut tx, AuditActor::System, &object("P-1", Visibility::Public)) .await .unwrap(); tx.commit().await.unwrap(); // by-id: public visible, draft hidden assert!(catalog::public_object_by_id(db.pool(), pub_id).await.unwrap().is_some()); assert!(catalog::public_object_by_id(db.pool(), draft).await.unwrap().is_none()); // list + count: only the public one let listed = catalog::list_public_objects(db.pool(), 50, 0).await.unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].id, pub_id); assert_eq!(catalog::count_public_objects(db.pool()).await.unwrap(), 1); // paging: offset past the end yields nothing assert!(catalog::list_public_objects(db.pool(), 50, 1).await.unwrap().is_empty()); } ``` - [ ] **Step 2: Run to verify it fails.** `DATABASE_URL= cargo test -p db --test visibility` → FAIL (`set_visibility` / `VisibilityError` / public readers missing). - [ ] **Step 3: Implement** in `crates/db/src/catalog.rs`. Extend the `domain` import (add `IllegalTransition`): ```rust use domain::{ AuditAction, AuditActor, CatalogueObject, FieldChange, FieldType, IllegalTransition, NewAuditEvent, ObjectId, ObjectInput, Visibility, }; ``` Add the visibility-eligible constant next to the existing `ENTITY_TYPE` const: ```rust /// The visibility value eligible for the public surface. const PUBLIC_VISIBILITY: &str = "public"; ``` Add the error type and `set_visibility` (place after `update_object`, before `delete_object`): ```rust /// Why changing an object's visibility failed. #[derive(Debug, thiserror::Error)] pub enum VisibilityError { #[error("object not found")] ObjectNotFound, #[error(transparent)] Illegal(#[from] IllegalTransition), #[error(transparent)] Db(#[from] sqlx::Error), } /// Move an object to `target` visibility, enforcing the stepwise state machine, and /// audit the change. Reuses [`update_object`]'s diff/audit path, so only `visibility` /// appears in the audit entry — and setting to the current value is an idempotent no-op /// (no row touch, no audit). Pass a transaction connection (`&mut tx`). pub async fn set_visibility( conn: &mut sqlx::PgConnection, actor: AuditActor, id: ObjectId, target: Visibility, ) -> Result<(), VisibilityError> { let Some(object) = object_by_id(&mut *conn, id).await? else { return Err(VisibilityError::ObjectNotFound); }; let new_visibility = object.visibility.transition_to(target)?; let mut input = object.to_input(); input.visibility = new_visibility; update_object(&mut *conn, actor, id, &input).await?; Ok(()) } ``` Add the public readers (place after `list_objects`): ```rust /// Fetch one **public** object by id. Returns `None` if the object is missing **or** /// not public — callers map both to 404 so non-public existence isn't revealed. pub async fn public_object_by_id<'e, E>( executor: E, id: ObjectId, ) -> Result, sqlx::Error> where E: sqlx::PgExecutor<'e>, { let sql = format!("SELECT {OBJECT_COLUMNS} FROM object WHERE id = $1 AND visibility = $2"); let row = sqlx::query(&sql) .bind(id.to_uuid()) .bind(PUBLIC_VISIBILITY) .fetch_optional(executor) .await?; row.map(map_object).transpose() } /// List **public** objects ordered by object number, with `limit`/`offset` paging. pub async fn list_public_objects<'e, E>( executor: E, limit: i64, offset: i64, ) -> Result, sqlx::Error> where E: sqlx::PgExecutor<'e>, { let sql = format!( "SELECT {OBJECT_COLUMNS} FROM object WHERE visibility = $1 \ ORDER BY object_number LIMIT $2 OFFSET $3" ); let rows = sqlx::query(&sql) .bind(PUBLIC_VISIBILITY) .bind(limit) .bind(offset) .fetch_all(executor) .await?; rows.into_iter().map(map_object).collect() } /// Count all public objects (for pagination totals). pub async fn count_public_objects<'e, E>(executor: E) -> Result where E: sqlx::PgExecutor<'e>, { let row = sqlx::query("SELECT count(*) AS n FROM object WHERE visibility = $1") .bind(PUBLIC_VISIBILITY) .fetch_one(executor) .await?; row.try_get("n") } ``` - [ ] **Step 4: Run to verify it passes.** `DATABASE_URL= cargo test -p db --test visibility` → PASS (5 tests). - [ ] **Step 5: Lint.** `cargo +nightly fmt`; `DATABASE_URL= cargo clippy -p db --all-targets -- -D warnings` → clean. - [ ] **Step 6: Commit.** ```bash git add crates/db git commit -m "feat(db): audited stepwise set_visibility + public-only object readers" ``` --- ## Task 3: `api` — public read API (`PublicView` + routes + OpenAPI) **Files:** modify `crates/api/Cargo.toml`, `crates/api/src/lib.rs`, `crates/api/src/openapi.rs`; create `crates/api/src/public.rs`, `crates/api/tests/public.rs`. - [ ] **Step 1: Cargo deps.** In `crates/api/Cargo.toml` `[dependencies]`, add `domain` and `uuid` (the projection consumes `domain::CatalogueObject`; the path handler parses a UUID): ```toml domain = { path = "../domain" } uuid = { workspace = true } ``` Add to `[dev-dependencies]` (the handler tests seed objects through `db` repos, which need `domain` types): ```toml domain = { path = "../domain" } ``` - [ ] **Step 2: Write the failing test** `crates/api/tests/public.rs`: ```rust use api::{AppState, build_app}; use axum::body::Body; use axum::http::{Request, StatusCode}; use db::catalog; use domain::{AuditActor, ObjectInput, Visibility}; use http_body_util::BodyExt; use sqlx::PgPool; use tower::ServiceExt; // for `oneshot` fn state(pool: PgPool) -> AppState { AppState { db: db::Db::from_pool(pool), app_name: "Test".to_string(), } } fn object(number: &str, name: &str, visibility: Visibility) -> ObjectInput { ObjectInput { object_number: number.into(), object_name: name.into(), number_of_objects: 1, brief_description: Some("a description".into()), current_location: Some("vault B".into()), // never-public; must NOT appear in output current_owner: Some("the museum".into()), // never-public recorder: None, recording_date: None, visibility, } } async fn body_json(resp: axum::http::Response) -> serde_json::Value { let bytes = resp.into_body().collect().await.unwrap().to_bytes(); serde_json::from_slice(&bytes).unwrap() } #[sqlx::test] async fn list_returns_only_public_as_public_view(pool: PgPool) { let db = db::Db::from_pool(pool.clone()); let mut tx = db.pool().begin().await.unwrap(); catalog::create_object(&mut tx, AuditActor::System, &object("D-1", "draft vase", Visibility::Draft)) .await .unwrap(); catalog::create_object(&mut tx, AuditActor::System, &object("P-1", "public vase", Visibility::Public)) .await .unwrap(); tx.commit().await.unwrap(); let app = build_app(state(pool)); let resp = app .oneshot(Request::builder().uri("/api/public/objects").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let json = body_json(resp).await; assert_eq!(json["total"], 1); assert_eq!(json["items"].as_array().unwrap().len(), 1); let item = &json["items"][0]; assert_eq!(item["object_number"], "P-1"); assert_eq!(item["object_name"], "public vase"); assert_eq!(item["brief_description"], "a description"); // never-public fields must be structurally absent assert!(item.get("current_location").is_none()); assert!(item.get("current_owner").is_none()); assert!(item.get("recorder").is_none()); assert!(item.get("visibility").is_none()); } #[sqlx::test] async fn get_public_object_returns_it(pool: PgPool) { let db = db::Db::from_pool(pool.clone()); let mut tx = db.pool().begin().await.unwrap(); let id = catalog::create_object( &mut tx, AuditActor::System, &object("P-1", "public vase", Visibility::Public), ) .await .unwrap(); tx.commit().await.unwrap(); let app = build_app(state(pool)); let resp = app .oneshot( Request::builder() .uri(format!("/api/public/objects/{id}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let json = body_json(resp).await; assert_eq!(json["object_number"], "P-1"); assert!(json.get("current_location").is_none()); } #[sqlx::test] async fn get_non_public_object_is_404(pool: PgPool) { let db = db::Db::from_pool(pool.clone()); let mut tx = db.pool().begin().await.unwrap(); let id = catalog::create_object( &mut tx, AuditActor::System, &object("D-1", "draft vase", Visibility::Draft), ) .await .unwrap(); tx.commit().await.unwrap(); let app = build_app(state(pool)); let resp = app .oneshot( Request::builder() .uri(format!("/api/public/objects/{id}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); // not 403 — don't leak existence } #[sqlx::test] async fn get_missing_object_is_404(pool: PgPool) { let app = build_app(state(pool)); let resp = app .oneshot( Request::builder() .uri(format!("/api/public/objects/{}", domain::ObjectId::new())) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[sqlx::test] async fn openapi_lists_the_public_paths(pool: PgPool) { let app = build_app(state(pool)); let resp = app .oneshot( Request::builder() .uri("/api-docs/openapi.json") .body(Body::empty()) .unwrap(), ) .await .unwrap(); let json = body_json(resp).await; assert!(json["paths"]["/api/public/objects"].is_object()); assert!(json["paths"]["/api/public/objects/{id}"].is_object()); } ``` - [ ] **Step 3: Run to verify it fails.** `DATABASE_URL= cargo test -p api --test public` → FAIL (`public` module / routes missing). - [ ] **Step 4: Implement** `crates/api/src/public.rs`: ```rust //! Public, unauthenticated, read-only surface (`/api/public/**`). //! //! Serves only `public` records as a [`PublicView`] — a projection that carries //! ONLY public-safe fields. The never-public set (location, owner, recorder, dates, //! and any flexible fields) is excluded by construction: the type lacks those fields, //! so leaking one here is impossible. Per-field publishability (to surface selected //! flexible fields) is post-MVP. use axum::{ Json, Router, extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, routing::get, }; use domain::{CatalogueObject, ObjectId}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use crate::AppState; /// A catalogue object as exposed on the public surface (public-safe fields only). #[derive(Serialize, ToSchema)] pub(crate) struct PublicView { /// Stable object id (UUID). pub id: String, pub object_number: String, pub object_name: String, pub brief_description: Option, } impl PublicView { fn from_object(object: &CatalogueObject) -> Self { PublicView { id: object.id.to_string(), object_number: object.object_number.clone(), object_name: object.object_name.clone(), brief_description: object.brief_description.clone(), } } } /// A page of public objects. #[derive(Serialize, ToSchema)] pub(crate) struct PublicObjectPage { pub items: Vec, /// Total number of public objects (independent of paging). pub total: i64, pub limit: i64, pub offset: i64, } /// Pagination query parameters with sane defaults and a hard cap. #[derive(Deserialize)] pub(crate) struct Pagination { limit: Option, offset: Option, } const DEFAULT_LIMIT: i64 = 50; const MAX_LIMIT: i64 = 200; impl Pagination { fn limit(&self) -> i64 { self.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT) } fn offset(&self) -> i64 { self.offset.unwrap_or(0).max(0) } } /// List public objects (paginated). #[utoipa::path( get, path = "/api/public/objects", params( ("limit" = Option, Query, description = "Max items (1..=200, default 50)"), ("offset" = Option, Query, description = "Items to skip (default 0)") ), responses((status = 200, body = PublicObjectPage)) )] pub(crate) async fn list_objects( State(state): State, Query(page): Query, ) -> Result, StatusCode> { let (limit, offset) = (page.limit(), page.offset()); let objects = db::catalog::list_public_objects(state.db.pool(), limit, offset) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let total = db::catalog::count_public_objects(state.db.pool()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(PublicObjectPage { items: objects.iter().map(PublicView::from_object).collect(), total, limit, offset, })) } /// Get one public object by id. Returns 404 if missing OR not public. #[utoipa::path( get, path = "/api/public/objects/{id}", params(("id" = String, Path, description = "Object id (UUID)")), responses( (status = 200, body = PublicView), (status = 404, description = "No public object with that id") ) )] pub(crate) async fn get_object( State(state): State, Path(id): Path, ) -> impl IntoResponse { let Ok(object_id) = id.parse::() else { return StatusCode::NOT_FOUND.into_response(); }; match db::catalog::public_object_by_id(state.db.pool(), object_id).await { Ok(Some(object)) => Json(PublicView::from_object(&object)).into_response(), Ok(None) => StatusCode::NOT_FOUND.into_response(), Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), } } /// Public routes, parameterized over [`AppState`]. pub(crate) fn routes() -> Router { Router::new() .route("/api/public/objects", get(list_objects)) .route("/api/public/objects/{id}", get(get_object)) } ``` NOTE: axum 0.8 path syntax is `{id}` (braces), matching the existing routes. `ObjectId: FromStr` exists (id macro). `state.db.pool()` returns the `&PgPool` (used by the health readiness handler too). In `crates/api/src/lib.rs`, declare the module and merge its routes: ```rust mod health; mod openapi; mod public; ``` ```rust pub fn build_app(state: AppState) -> Router { Router::new() .merge(health::routes()) .merge(openapi::routes()) .merge(public::routes()) .with_state(state) } ``` In `crates/api/src/openapi.rs`, register the public paths + schemas. Update the imports and the `#[openapi(...)]` attribute: ```rust use crate::{AppState, health, public}; ``` ```rust #[derive(OpenApi)] #[openapi( paths(health::live, health::ready, public::list_objects, public::get_object), components(schemas(health::Live, health::Ready, public::PublicView, public::PublicObjectPage)), info(title = "Collection Management System", version = "0.0.0") )] struct ApiDoc; ``` - [ ] **Step 5: Run to verify it passes.** `DATABASE_URL= cargo test -p api --test public` → PASS (5 tests). Re-run the existing `health` test too: `DATABASE_URL= cargo test -p api` → all PASS. - [ ] **Step 6: Full workspace check.** ```bash cargo +nightly fmt --check DATABASE_URL= cargo clippy --workspace --all-targets -- -D warnings DATABASE_URL= MEILI_URL= MEILI_MASTER_KEY= cargo test --workspace ``` Expected: all green. (`search` tests need the MEILI env vars; the rest need `DATABASE_URL`.) - [ ] **Step 7: Commit.** ```bash git add crates/api git commit -m "feat(api): public read API (PublicView projection, paginated list + get, OpenAPI)" ``` --- ## Self-Review (completed) **Spec coverage (VISION "Publishing & public access" [MVP]; arch spec §7, §9, §14):** - Record-level visibility draft/internal/public with a type-driven state machine → Task 1 (`transition_to`/`IllegalTransition`). ✓ - Fixed never-public field set; public API serves only public records via `PublicView` → Task 3 (`PublicView` carries only safe fields; db filters `visibility='public'`). ✓ - Public surface `/api/public/**`, unauthenticated, read-only, OpenAPI (utoipa) → Task 3. ✓ - All SQL stays in `db`; `api` calls repos → Tasks 2–3. ✓ - Audited writes (visibility change in the amendment history) → Task 2 reuses `update_object`'s audit. ✓ - 404 (not 403) for non-public → Task 3 handler + test. ✓ **Placeholder scan:** none. ``/`` are the documented env values. **Type consistency:** `Visibility::{transition_to, can_transition_to}` + `IllegalTransition` defined in Task 1 and consumed in Tasks 2–3; `set_visibility`/`VisibilityError`/`public_object_by_id`/`list_public_objects`/`count_public_objects` defined in Task 2 and consumed by Task 3 handlers; `PublicView`/`PublicObjectPage`/`Pagination` defined and used consistently within Task 3; reuses existing `catalog::{create_object, object_by_id, update_object, OBJECT_COLUMNS, map_object}`, `audit::history_for`, `AppState`, `db.pool()`, and the axum `{id}` path convention. ## Notes for follow-on plans - **Admin transition endpoint + auth:** the HTTP surface to *invoke* `set_visibility` (publish/unpublish) is a privileged write — it lands with the auth phase via an `Authorized` extractor. `domain` may then add ergonomic `publish()`/`unpublish()` wrappers over `transition_to` (omitted now to avoid dead code). - **Required-field completeness on publish:** `set_object_fields` defers required-completeness to "the publish gate" (see `catalog.rs` doc comment). A future gate should validate that all `required` field definitions are present before allowing `→ Public`. **File a gitea follow-up.** - **On-write search sync:** when `set_visibility` / catalogue writes commit, the API/service layer should re-index (`index_object`) or drop from the index — relates to the Plan 6 deferred on-write sync. - **Per-field publishability (post-MVP):** replaces the core-only `PublicView` with a registry-driven projection that can surface selected flexible fields. - **Keyset pagination:** `list_public_objects` uses `LIMIT/OFFSET` (fine for MVP). Switch to keyset when collections grow (the same TODO already noted on `list_objects`). - **Public-facing search (post-MVP):** the `search` crate already stores `visibility` as filterable; add a `with_filter("visibility = public")` variant when public search is built.