From da2db11a30cf3fe41b5cf69f656077e796c6db3b Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 10:09:46 +0200 Subject: [PATCH] docs: add Plan 4 (Field-definition registry) implementation plan Type-driven FieldType enum carrying binding; field_definition + labels tables with a CHECK enforcing term<->vocabulary; create/read/list repository. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-02-field-definition-registry.md | 487 ++++++++++++++++++ 1 file changed, 487 insertions(+) create mode 100644 docs/plans/2026-06-02-field-definition-registry.md diff --git a/docs/plans/2026-06-02-field-definition-registry.md b/docs/plans/2026-06-02-field-definition-registry.md new file mode 100644 index 0000000..c5f2aea --- /dev/null +++ b/docs/plans/2026-06-02-field-definition-registry.md @@ -0,0 +1,487 @@ +# Field-Definition Registry 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 "schema of schemas" for Approach C's flexible layer — a registry of field definitions (key, type, vocabulary/authority binding, required, group, multilingual labels). This is half of the flexible-fields subsystem; the object JSONB values + validation + audit are the next plan (Plan 5). + +**Architecture:** `domain` holds `FieldDefinitionId`, a type-driven `FieldType` enum that *carries its binding* (a `Term` always has a `VocabularyId`; a non-term never does — illegal states unrepresentable), and `FieldDefinition`/`NewFieldDefinition`. `db::fields` owns the `field_definition` + `field_definition_label` tables (migration 0004) and a create/read/list repository. The DB enforces the type↔binding invariant with a CHECK constraint mirroring the enum. No values, no validation engine, no HTTP yet. + +**Tech Stack:** Rust 2024, sqlx 0.8 (`time`+`json`), `serde_json` (labels json_agg). Tests use `#[sqlx::test]`. + +## Design decisions (approved) +- Split flexible fields into **this plan (registry)** and **Plan 5 (values + validation + audit)**. +- `data_type` set: `text`, `localized_text`, `integer`, `date`, `boolean`, `term`, `authority`. +- `FieldType` is a type-driven enum carrying the binding; the DB stores `(data_type, vocabulary_id, authority_kind)` with a CHECK enforcing `term ⇔ vocabulary_id present`. +- Field definitions carry multilingual display labels (reusing `LocalizedLabel`) and a `group_key`. +- Scope: create/read/list of definitions. Update/delete of definitions deferred (admin UI, Plan 10). + +## 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 + FieldDefinitionId + src/field_definition.rs FieldType, FieldDefinition, NewFieldDefinition + src/lib.rs re-exports +crates/db/ + migrations/0004_field_definition.sql + src/fields.rs create/read/list field definitions + src/lib.rs pub mod fields; + tests/fields.rs +``` + +--- + +## Task 1: `domain` — field definition types + +**Files:** modify `crates/domain/src/id.rs`, `crates/domain/src/lib.rs`; create `crates/domain/src/field_definition.rs`. + +- [ ] **Step 1: Add `FieldDefinitionId`** to `crates/domain/src/id.rs` (another `id_newtype!` invocation): +```rust +id_newtype!( + /// Identifier for a flexible-field definition. + FieldDefinitionId +); +``` + +- [ ] **Step 2: Create `crates/domain/src/field_definition.rs`:** +```rust +use crate::{AuthorityKind, FieldDefinitionId, LocalizedLabel, VocabularyId}; + +/// The type of a flexible field, carrying its binding where applicable. +/// +/// Type-driven: a `Term` always names its vocabulary; a non-term never carries one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FieldType { + Text, + LocalizedText, + Integer, + Date, + Boolean, + Term { vocabulary_id: VocabularyId }, + Authority { kind: Option }, +} + +impl FieldType { + /// The stored discriminant string. + pub fn kind_str(&self) -> &'static str { + match self { + FieldType::Text => "text", + FieldType::LocalizedText => "localized_text", + FieldType::Integer => "integer", + FieldType::Date => "date", + FieldType::Boolean => "boolean", + FieldType::Term { .. } => "term", + FieldType::Authority { .. } => "authority", + } + } + + /// Decompose into the three stored columns: `(data_type, vocabulary_id, authority_kind)`. + pub fn to_parts(&self) -> (&'static str, Option, Option) { + match self { + FieldType::Term { vocabulary_id } => ("term", Some(*vocabulary_id), None), + FieldType::Authority { kind } => ("authority", None, *kind), + other => (other.kind_str(), None, None), + } + } + + /// Reconstruct from the stored columns. `None` for an unknown or inconsistent combo + /// (e.g. `term` without a vocabulary). + pub fn from_parts( + data_type: &str, + vocabulary_id: Option, + authority_kind: Option, + ) -> Option { + match data_type { + "text" => Some(FieldType::Text), + "localized_text" => Some(FieldType::LocalizedText), + "integer" => Some(FieldType::Integer), + "date" => Some(FieldType::Date), + "boolean" => Some(FieldType::Boolean), + "term" => vocabulary_id.map(|vocabulary_id| FieldType::Term { vocabulary_id }), + "authority" => Some(FieldType::Authority { kind: authority_kind }), + _ => None, + } + } +} + +/// A registered flexible field, with its multilingual display labels. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FieldDefinition { + pub id: FieldDefinitionId, + pub key: String, + pub field_type: FieldType, + pub required: bool, + pub group_key: Option, + pub labels: Vec, +} + +/// A field definition to be created. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewFieldDefinition { + pub key: String, + pub field_type: FieldType, + pub required: bool, + pub group_key: Option, + pub labels: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn field_type_round_trips_through_parts() { + let v = VocabularyId::new(); + let cases = [ + FieldType::Text, + FieldType::LocalizedText, + FieldType::Integer, + FieldType::Date, + FieldType::Boolean, + FieldType::Term { vocabulary_id: v }, + FieldType::Authority { kind: Some(AuthorityKind::Person) }, + FieldType::Authority { kind: None }, + ]; + for ft in cases { + let (data_type, vocabulary_id, authority_kind) = ft.to_parts(); + assert_eq!(FieldType::from_parts(data_type, vocabulary_id, authority_kind), Some(ft)); + } + } + + #[test] + fn term_without_vocabulary_is_invalid() { + assert_eq!(FieldType::from_parts("term", None, None), None); + } + + #[test] + fn unknown_type_is_none() { + assert_eq!(FieldType::from_parts("blob", None, None), None); + } +} +``` + +- [ ] **Step 3: Update `crates/domain/src/lib.rs`:** add `mod field_definition;` (sorted: audit, authority, field_definition, id, label, object, vocabulary), add `FieldDefinitionId` to the id re-export, and add: +```rust +pub use field_definition::{FieldDefinition, FieldType, NewFieldDefinition}; +``` + +- [ ] **Step 4: Test + lint.** `cargo test -p domain` → all pass. `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 field definition types (FieldType, FieldDefinition)" +``` + +--- + +## Task 2: `db` migration — field_definition tables + +**Files:** create `crates/db/migrations/0004_field_definition.sql`; modify `crates/db/tests/migrate.rs`. + +- [ ] **Step 1: Create `crates/db/migrations/0004_field_definition.sql`:** +```sql +-- Registry of flexible field definitions (the "schema of schemas"). +CREATE TABLE field_definition ( + id UUID PRIMARY KEY, + key TEXT NOT NULL UNIQUE CHECK (key <> ''), + data_type TEXT NOT NULL CHECK (data_type IN + ('text', 'localized_text', 'integer', 'date', 'boolean', 'term', 'authority')), + vocabulary_id UUID REFERENCES vocabulary (id) ON DELETE RESTRICT, + authority_kind TEXT CHECK (authority_kind IN ('person', 'organisation', 'place')), + required BOOLEAN NOT NULL DEFAULT false, + group_key TEXT CHECK (group_key <> ''), + -- A term field must name a vocabulary; any other type must not. + CONSTRAINT term_has_vocabulary CHECK ((data_type = 'term') = (vocabulary_id IS NOT NULL)), + -- authority_kind is only meaningful for authority fields. + CONSTRAINT authority_kind_only_for_authority + CHECK (authority_kind IS NULL OR data_type = 'authority') +); + +CREATE TABLE field_definition_label ( + field_definition_id UUID NOT NULL REFERENCES field_definition (id) ON DELETE CASCADE, + lang TEXT NOT NULL CHECK (lang <> ''), + label TEXT NOT NULL CHECK (label <> ''), + PRIMARY KEY (field_definition_id, lang) +); +``` + +- [ ] **Step 2: Append to `crates/db/tests/migrate.rs`:** +```rust +#[sqlx::test] +async fn migrate_creates_field_definition_tables(pool: PgPool) { + let db = Db::from_pool(pool); + for table in ["field_definition", "field_definition_label"] { + let regclass: Option = + sqlx::query_scalar(&format!("SELECT to_regclass('public.{table}')::text")) + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(regclass.as_deref(), Some(table), "table {table} should exist"); + } +} +``` + +- [ ] **Step 3: Run + lint.** `DATABASE_URL= cargo test -p db --test migrate` → 4 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 field_definition tables" +``` + +--- + +## Task 3: `db::fields` repository + +**Files:** create `crates/db/src/fields.rs`; modify `crates/db/src/lib.rs`; create `crates/db/tests/fields.rs`. + +- [ ] **Step 1: Write the failing test** `crates/db/tests/fields.rs`: +```rust +use db::{Db, fields, vocab}; +use domain::{AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition}; +use sqlx::PgPool; + +fn labels() -> Vec { + vec![ + LocalizedLabel { lang: "sv".into(), label: "material".into() }, + LocalizedLabel { lang: "en".into(), label: "material".into() }, + ] +} + +#[sqlx::test] +async fn text_field_round_trips(pool: PgPool) { + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + let id = fields::create_field_definition( + &mut tx, + &NewFieldDefinition { + key: "comments".into(), + field_type: FieldType::Text, + required: false, + group_key: Some("identification".into()), + labels: labels(), + }, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let def = fields::field_definition_by_key(db.pool(), "comments").await.unwrap().unwrap(); + assert_eq!(def.id, id); + assert_eq!(def.field_type, FieldType::Text); + assert_eq!(def.group_key.as_deref(), Some("identification")); + assert_eq!(def.labels.len(), 2); + assert!(fields::field_definition_by_key(db.pool(), "nope").await.unwrap().is_none()); +} + +#[sqlx::test] +async fn term_and_authority_fields_round_trip_their_binding(pool: PgPool) { + let db = Db::from_pool(pool); + let material = vocab::create_vocabulary(db.pool(), "material").await.unwrap(); + + let mut tx = db.pool().begin().await.unwrap(); + fields::create_field_definition( + &mut tx, + &NewFieldDefinition { + key: "material".into(), + field_type: FieldType::Term { vocabulary_id: material.id }, + required: true, + group_key: None, + labels: labels(), + }, + ) + .await + .unwrap(); + fields::create_field_definition( + &mut tx, + &NewFieldDefinition { + key: "maker".into(), + field_type: FieldType::Authority { kind: Some(AuthorityKind::Person) }, + required: false, + group_key: None, + labels: vec![LocalizedLabel { lang: "en".into(), label: "maker".into() }], + }, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let material_def = fields::field_definition_by_key(db.pool(), "material").await.unwrap().unwrap(); + assert_eq!(material_def.field_type, FieldType::Term { vocabulary_id: material.id }); + assert!(material_def.required); + + let maker_def = fields::field_definition_by_key(db.pool(), "maker").await.unwrap().unwrap(); + assert_eq!(maker_def.field_type, FieldType::Authority { kind: Some(AuthorityKind::Person) }); + + let all = fields::list_field_definitions(db.pool()).await.unwrap(); + assert_eq!(all.len(), 2); +} +``` + +- [ ] **Step 2: Run to verify it fails.** `DATABASE_URL= cargo test -p db --test fields` → FAIL. + +- [ ] **Step 3: Implement** `crates/db/src/fields.rs`: +```rust +//! Registry of flexible field definitions. + +use domain::{ + AuthorityKind, FieldDefinition, FieldDefinitionId, FieldType, LocalizedLabel, + NewFieldDefinition, VocabularyId, +}; +use sqlx::Row; + +/// Labels aggregated per row as JSON, to read a definition and its labels in one query. +const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', fdl.lang, 'label', fdl.label) \ + ORDER BY fdl.lang) FILTER (WHERE fdl.field_definition_id IS NOT NULL), '[]'::json)"; + +const SELECT_COLUMNS: &str = + "fd.id, fd.key, fd.data_type, fd.vocabulary_id, fd.authority_kind, fd.required, fd.group_key"; + +/// Create a field definition and its labels. Multiple statements — pass a +/// transaction connection (`&mut *tx`) for atomicity. +pub async fn create_field_definition( + conn: &mut sqlx::PgConnection, + new: &NewFieldDefinition, +) -> Result { + let id = FieldDefinitionId::new(); + let (data_type, vocabulary_id, authority_kind) = new.field_type.to_parts(); + + sqlx::query( + "INSERT INTO field_definition \ + (id, key, data_type, vocabulary_id, authority_kind, required, group_key) \ + VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind(id.to_uuid()) + .bind(&new.key) + .bind(data_type) + .bind(vocabulary_id.map(|v| v.to_uuid())) + .bind(authority_kind.map(|k| k.as_str())) + .bind(new.required) + .bind(new.group_key.as_deref()) + .execute(&mut *conn) + .await?; + + for label in &new.labels { + sqlx::query( + "INSERT INTO field_definition_label (field_definition_id, lang, label) \ + VALUES ($1, $2, $3)", + ) + .bind(id.to_uuid()) + .bind(&label.lang) + .bind(&label.label) + .execute(&mut *conn) + .await?; + } + + Ok(id) +} + +/// Look up a field definition by its key (with labels). +pub async fn field_definition_by_key<'e, E>( + executor: E, + key: &str, +) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + let sql = format!( + "SELECT {SELECT_COLUMNS}, {LABELS_JSON} AS labels \ + FROM field_definition fd \ + LEFT JOIN field_definition_label fdl ON fdl.field_definition_id = fd.id \ + WHERE fd.key = $1 GROUP BY fd.id" + ); + let row = sqlx::query(&sql).bind(key).fetch_optional(executor).await?; + row.map(map_field_definition).transpose() +} + +/// List all field definitions (with labels), ordered by key. +pub async fn list_field_definitions<'e, E>( + executor: E, +) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + let sql = format!( + "SELECT {SELECT_COLUMNS}, {LABELS_JSON} AS labels \ + FROM field_definition fd \ + LEFT JOIN field_definition_label fdl ON fdl.field_definition_id = fd.id \ + GROUP BY fd.id ORDER BY fd.key" + ); + let rows = sqlx::query(&sql).fetch_all(executor).await?; + rows.into_iter().map(map_field_definition).collect() +} + +fn map_field_definition(row: sqlx::postgres::PgRow) -> Result { + let data_type: String = row.try_get("data_type")?; + let vocabulary_id: Option = row.try_get("vocabulary_id")?; + let authority_kind: Option = row.try_get("authority_kind")?; + + let authority_kind = authority_kind + .map(|k| { + AuthorityKind::from_db(&k) + .ok_or_else(|| sqlx::Error::Decode(format!("unknown authority kind: {k}").into())) + }) + .transpose()?; + + let field_type = FieldType::from_parts( + &data_type, + vocabulary_id.map(VocabularyId::from_uuid), + authority_kind, + ) + .ok_or_else(|| { + sqlx::Error::Decode(format!("inconsistent field type stored: {data_type}").into()) + })?; + + let labels: sqlx::types::Json> = row.try_get("labels")?; + + Ok(FieldDefinition { + id: FieldDefinitionId::from_uuid(row.try_get("id")?), + key: row.try_get("key")?, + field_type, + required: row.try_get("required")?, + group_key: row.try_get("group_key")?, + labels: labels.0, + }) +} +``` +Add to `crates/db/src/lib.rs` (top-level): `pub mod fields;` + +- [ ] **Step 4: Run to verify it passes.** `DATABASE_URL= cargo test -p db --test fields` → PASS (2 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 field-definition registry repository" +``` + +--- + +## Self-Review (completed) + +**Spec coverage (§6.2 registry portion):** +- Field-definition registry: key, type, binding, required, group, multilingual labels → Tasks 1–3. ✓ +- Type-driven `FieldType` carrying binding; DB CHECK enforces `term ⇔ vocabulary` → Task 1 (enum) + Task 2 (CHECK). ✓ +- Approved `data_type` set incl. `localized_text` → Task 1. ✓ +- Create/read/list; update/delete deferred to admin UI. ✓ (intentional) +- SQL confined to `db`; `domain` I/O-free. ✓ +- No values/validation/HTTP (Plan 5+). ✓ (intentional) + +**Placeholder scan:** none. `` is the documented `DATABASE_URL`. + +**Type consistency:** `FieldType`/`FieldDefinition`/`NewFieldDefinition`/`FieldDefinitionId` names+fields identical across `domain` (Task 1), the repo (Task 3), tests. `to_parts`/`from_parts` are inverse (tested in Task 1) and used symmetrically in `create_field_definition` (to_parts → binds) and `map_field_definition` (from_parts ← columns). The DB CHECK `(data_type='term') = (vocabulary_id IS NOT NULL)` mirrors `from_parts` returning None for term-without-vocabulary. + +## Notes for follow-on plans +- **Plan 5 (values + validation + audit):** add `object.fields jsonb`, set/get with validation against this registry (type match; `term`/`authority` values resolved via `vocab::resolve_term`/`authority::resolve_authority`), and audit flexible-field changes on the object. Seed the Spectrum Cataloguing field set there (or a small follow-on) using `reference/spectrum-5.0-cataloguing-units-of-information.md`. +- Update/delete of field definitions (and the impact on existing values) is an admin-UI concern (Plan 10). +- `list_field_definitions` unbounded — covered by the pagination follow-up (#10) before API exposure.