diff --git a/crates/bds-core/src/db/migrations.rs b/crates/bds-core/src/db/migrations.rs index 3a0c97c..2d57685 100644 --- a/crates/bds-core/src/db/migrations.rs +++ b/crates/bds-core/src/db/migrations.rs @@ -92,6 +92,78 @@ mod tests { )); } + #[test] + fn mcp_proposal_enum_constraints_round_trip_and_reject_unknown_values() { + use crate::db::queries::{mcp_proposal, project}; + use crate::model::{McpProposal, ProposalKind, ProposalStatus}; + + let db = migrated_database(); + project::insert_project(db.conn(), &project::make_test_project("p1", "blog")).unwrap(); + let kinds = [ + ProposalKind::DraftPost, + ProposalKind::ProposeScript, + ProposalKind::ProposeTemplate, + ProposalKind::ProposeMediaTranslation, + ProposalKind::ProposeMediaMetadata, + ProposalKind::ProposePostMetadata, + ]; + let statuses = [ + ProposalStatus::Pending, + ProposalStatus::Executing, + ProposalStatus::Accepted, + ProposalStatus::Rejected, + ProposalStatus::Expired, + ]; + + for (index, kind) in kinds.into_iter().enumerate() { + let status = statuses[index % statuses.len()]; + let id = format!("proposal-{index}"); + let proposal = McpProposal { + id: id.clone(), + project_id: "p1".into(), + kind, + status, + entity_id: id.clone(), + data: "{}".into(), + result: None, + created_at: 1, + expires_at: 2, + resolved_at: None, + }; + mcp_proposal::insert_proposal(db.conn(), &proposal).unwrap(); + let stored = mcp_proposal::get_proposal(db.conn(), &id).unwrap(); + assert_eq!(stored.kind, kind); + assert_eq!(stored.status, status); + } + + for (id, kind, status) in [ + ("invalid-kind", "unknown", "pending"), + ("invalid-status", "draft_post", "unknown"), + ] { + let result = db.conn().with(|connection| { + diesel::insert_into(mcp_proposals::table) + .values(( + mcp_proposals::id.eq(id), + mcp_proposals::project_id.eq("p1"), + mcp_proposals::kind.eq(kind), + mcp_proposals::status.eq(status), + mcp_proposals::entity_id.eq(id), + mcp_proposals::data.eq("{}"), + mcp_proposals::created_at.eq(1_i64), + mcp_proposals::expires_at.eq(2_i64), + )) + .execute(connection) + }); + assert!(matches!( + result, + Err(diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::CheckViolation, + _ + )) + )); + } + } + #[test] fn migrations_create_every_persisted_table() { let db = migrated_database(); diff --git a/specs/schema.allium b/specs/schema.allium index c04608a..c0a99da 100644 --- a/specs/schema.allium +++ b/specs/schema.allium @@ -1,7 +1,7 @@ -- allium: 1 -- bDS Persistence Data Contract -- Scope: core (Wave 1 — exact compatibility contract) --- Distilled from: ../bDS2/src/main/database/schema.ts +-- Distilled from: crates/bds-core/migrations/ and ../bDS2/priv/repo/migrations/ -- -- This document specifies the persisted data model the rewrite must be able -- to read and write. It is the ground truth for storage compatibility. @@ -27,6 +27,23 @@ enum ScriptStatus { published } +enum McpProposalKind { + draft_post + propose_script + propose_template + propose_media_translation + propose_media_metadata + propose_post_metadata +} + +enum McpProposalStatus { + pending + executing + accepted + rejected + expired +} + -- ============================================================================ -- CORE ENTITIES -- ============================================================================ @@ -229,6 +246,7 @@ entity ChatConversation { title: String model: String? -- Model used for conversation copilot_session_id: String? -- Legacy, no longer used + surface_state: String? -- Serialized form/tab/dismissal state for inline chat surfaces created_at: Timestamp updated_at: Timestamp } @@ -315,6 +333,26 @@ entity AiEndpointModel { updated_at: Timestamp } +entity McpProposal { + -- RuDS persistence record. bDS2 stores the common kind, status, + -- entity_id, data, created_at, and expires_at subset without project, + -- result, or resolution timestamps. + id: String -- UUID v4 + project_id: String + kind: McpProposalKind + status: McpProposalStatus + entity_id: String + data: String -- Serialized inert proposal payload + result: String? -- Serialized resolution result + created_at: Timestamp + expires_at: Timestamp + resolved_at: Timestamp? +} + +-- INTENTIONAL DIVERGENCE: bDS2 persists publish_jobs, but RuDS keeps publish +-- jobs in memory. Maintainer decision #86 found no value in persisted job +-- history, so no PublishJob persistence entity or table exists in RuDS. + -- ============================================================================ -- EMBEDDINGS TABLES -- ============================================================================ @@ -528,6 +566,7 @@ surface ChatConversationRecordSurface { conversation.title conversation.model when conversation.model != null conversation.copilot_session_id when conversation.copilot_session_id != null + conversation.surface_state when conversation.surface_state != null conversation.created_at conversation.updated_at } @@ -610,6 +649,22 @@ surface AiEndpointModelRecordSurface { endpoint_model.updated_at } +surface McpProposalRecordSurface { + context proposal: McpProposal + + exposes: + proposal.id + proposal.project_id + proposal.kind + proposal.status + proposal.entity_id + proposal.data + proposal.result when proposal.result != null + proposal.created_at + proposal.expires_at + proposal.resolved_at when proposal.resolved_at != null +} + surface EmbeddingKeyRecordSurface { context key: EmbeddingKey @@ -759,6 +814,16 @@ invariant UniqueDismissedDuplicatePair { and a.post_id_b = b.post_id_b) } +invariant UniqueMcpProposalEntityStatus { + -- Enforced by SQLite unique index mcp_proposals_entity_idx. + for a in McpProposals: + for b in McpProposals: + a != b implies + not (a.kind = b.kind + and a.entity_id = b.entity_id + and a.status = b.status) +} + -- ============================================================================ -- FTS5 VIRTUAL TABLE SCHEMAS (Snowball Stemmer Integration) -- ============================================================================ @@ -791,7 +856,8 @@ value Fts5MediaSchema { value MigrationVersion { -- Schema version tracking via embedded Diesel migrations - -- Current version: 20260718000000 (initial compatible schema baseline) + -- Current version: 2026-07-22-161429-0000 + -- (MCP proposal entity/status uniqueness and non-null entity IDs) -- Migration directories located in: crates/bds-core/migrations/ -- Note: Migration list documented in comments, not as Allium value }