From 223a6ed011bd2a66c58ea8bfb9c52758fc9441c6 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 10:42:57 +0900 Subject: [PATCH] feat: enforce workspace-scoped resource references --- crates/ticket/src/sqlite_schema.rs | 10 +- crates/workspace-server/src/authority.rs | 47 +- crates/workspace-server/src/server.rs | 76 +- crates/workspace-server/src/store.rs | 872 +++++++++++++++++++++-- 4 files changed, 887 insertions(+), 118 deletions(-) diff --git a/crates/ticket/src/sqlite_schema.rs b/crates/ticket/src/sqlite_schema.rs index 14e13bbc..5dca7fc1 100644 --- a/crates/ticket/src/sqlite_schema.rs +++ b/crates/ticket/src/sqlite_schema.rs @@ -848,18 +848,16 @@ fn collect_foreign_key_diagnostics( ) }) .collect::>(); + // The Ticket component owns its required foreign keys, while an integrated host may + // strengthen Workspace/domain boundaries with additional references to host-owned + // tables. Reject missing component constraints, but do not treat those host extensions + // as Ticket schema drift. for missing in expected.difference(&actual) { push_diagnostic( diagnostics, format!("table {table:?} is missing foreign key {missing:?}"), ); } - for unexpected in actual.difference(&expected) { - push_diagnostic( - diagnostics, - format!("table {table:?} has unexpected foreign key {unexpected:?}"), - ); - } } fn collect_foreign_key_check_diagnostics( diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 6b038a4c..96dddde0 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -2843,12 +2843,6 @@ mod tests { write_ticket(dir.path(), "00000000001J5", "Second ticket", "planning"); write_ticket(dir.path(), "00000000001J6", "Third ticket", "planning"); let db_path = dir.path().join("workspace.db"); - SqliteTicketBackend::open(&db_path, "workspace-test") - .unwrap() - .import_from_local_backend(&ticket::LocalTicketBackend::new( - dir.path().join(".yoi/tickets"), - )) - .unwrap(); let store = SqliteWorkspaceStore::open(&db_path).unwrap(); store .upsert_workspace(&WorkspaceRecord { @@ -2861,6 +2855,27 @@ mod tests { }) .await .unwrap(); + SqliteTicketBackend::open(&db_path, "workspace-test") + .unwrap() + .import_from_local_backend(&ticket::LocalTicketBackend::new( + dir.path().join(".yoi/tickets"), + )) + .unwrap(); + rusqlite::Connection::open(&db_path) + .unwrap() + .execute_batch( + r#" +INSERT INTO workspace_resource_human_keys ( + workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at +) VALUES + ('workspace-test', 'ticket', '00000000001J2', 1, 'T-1', '2026-01-01T00:00:00Z'), + ('workspace-test', 'ticket', '00000000001J5', 2, 'T-2', '2026-01-01T00:00:00Z'), + ('workspace-test', 'ticket', '00000000001J6', 3, 'T-3', '2026-01-01T00:00:00Z'); +INSERT INTO workspace_resource_human_key_counters (workspace_id, resource_kind, next_sequence) +VALUES ('workspace-test', 'ticket', 4); +"#, + ) + .unwrap(); store .upsert_objective(&ObjectiveRecord { workspace_id: "workspace-test".to_string(), @@ -3216,6 +3231,26 @@ mod tests { }) .await .unwrap(); + rusqlite::Connection::open(&db_path) + .unwrap() + .execute_batch( + r#" +INSERT INTO typed_tickets ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + workflow_state, workflow_state_explicit +) VALUES + ('workspace-test', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'planning', 1), + ('workspace-test', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'planning', 1); +INSERT INTO workspace_resource_human_keys ( + workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at +) VALUES + ('workspace-test', 'ticket', '00000000001J2', 1, 'T-1', '2026-01-01T00:00:00Z'), + ('workspace-test', 'ticket', '00000000001J3', 2, 'T-2', '2026-01-01T00:00:00Z'); +INSERT INTO workspace_resource_human_key_counters (workspace_id, resource_kind, next_sequence) +VALUES ('workspace-test', 'ticket', 3); +"#, + ) + .unwrap(); let authority = SqliteWorkspaceAuthority::new(&db_path, "workspace-test").unwrap(); let created = authority diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 75206cb5..7cdfac14 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -12889,38 +12889,10 @@ mod tests { .is_err() ); - let ticket = browser_ticket_backend(&api) - .unwrap() - .create(create_input) - .unwrap(); - let flow_ticket_launch = WorkerSpawnRequest { - requested_worker_name: Some("cross-workspace-ticket".to_string()), - intent: WorkerSpawnIntent::TicketRole { - ticket_id: ticket.id, - role: TicketWorkerRole::Coder, - }, - acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { - expected_segments: 2, - }, - profile: ProfileSelector::Builtin("builtin:coder".to_string()), - ticket_assignment: None, - initial_submit: vec![ - Segment::Flow { - selector: "builtin:coder-review".to_string(), - }, - Segment::text("Implement the Ticket"), - ], - working_directory_request: None, - resolved_working_directory_request: None, - resolved_working_directory: None, - resolved_config_bundle: None, - resolved_worker_observation_enabled: false, - resolved_worker_observation_grants: Vec::new(), - resolved_control_operation: None, - resolved_workspace_api: None, - }; assert!( - api.validate_worker_spawn_repository_scope(&flow_ticket_launch) + browser_ticket_backend(&api) + .unwrap() + .create(create_input) .is_err() ); @@ -14392,27 +14364,7 @@ mod tests { let mut missing = ticket::NewTicket::new("Missing target"); missing.repository_id = Some("unknown".to_owned()); - let missing = backend.create(missing).unwrap(); - assert!(matches!( - backend.mark_ready( - TicketIdOrSlug::Id(missing.id.clone()), - ticket::TicketMarkReady { - operation_key: "missing-repository".to_owned(), - reason: None, - author: None, - intake_summary: None, - }, - ), - Err(ticket::TicketError::UnknownTargetRepository(_)) - )); - assert_eq!( - backend - .show(TicketIdOrSlug::Id(missing.id)) - .unwrap() - .meta - .workflow_state, - TicketWorkflowState::Planning - ); + assert!(backend.create(missing).is_err()); assert!(matches!( backend.set_workflow_state( TicketIdOrSlug::Id(ticket_ref.id), @@ -18947,6 +18899,26 @@ mod tests { }) .await .unwrap(); + rusqlite::Connection::open(&config.database_path) + .unwrap() + .execute_batch( + r#" +INSERT INTO typed_tickets ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + workflow_state, workflow_state_explicit +) VALUES + ('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'planning', 1), + ('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'planning', 1); +INSERT INTO workspace_resource_human_keys ( + workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at +) VALUES + ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', '00000000001J2', 1, 'T-1', '2026-01-01T00:00:00Z'), + ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', '00000000001J3', 2, 'T-2', '2026-01-01T00:00:00Z'); +INSERT INTO workspace_resource_human_key_counters (workspace_id, resource_kind, next_sequence) +VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); +"#, + ) + .unwrap(); let api = WorkspaceApi::new_with_execution_backend( config, store, diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 6fd3e48b..742c51a4 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -215,6 +215,11 @@ const MIGRATIONS: &[Migration] = &[ name: "add Workspace resource human keys", apply: add_workspace_resource_human_keys, }, + Migration { + version: 39, + name: "enforce Workspace resource foreign keys", + apply: enforce_workspace_resource_foreign_keys, + }, ]; struct Migration { @@ -1030,7 +1035,7 @@ impl SqliteWorkspaceStore { apply_migrations_through(&candidate, i64::MAX)?; ticket::migrate_sqlite_ticket_schema(&candidate)?; merge_request::migrate(&candidate).map_err(|error| Error::Store(error.to_string()))?; - validate_workspace_repository_references(&candidate)?; + validate_workspace_resource_references(&candidate)?; let foreign_key_failures: i64 = candidate.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { row.get(0) @@ -1062,10 +1067,14 @@ impl SqliteWorkspaceStore { pub fn from_connection(conn: Connection) -> Result { configure_sqlite(&conn)?; - apply_migrations(&conn)?; - ticket::migrate_sqlite_ticket_schema(&conn)?; - merge_request::migrate(&conn).map_err(|error| Error::Store(error.to_string()))?; - validate_workspace_repository_references(&conn)?; + apply_migrations(&conn) + .map_err(|error| Error::Store(format!("workspace schema migration failed: {error}")))?; + ticket::migrate_sqlite_ticket_schema(&conn) + .map_err(|error| Error::Store(format!("Ticket schema verification failed: {error}")))?; + merge_request::migrate(&conn).map_err(|error| { + Error::Store(format!("Merge Request schema verification failed: {error}")) + })?; + validate_workspace_resource_references(&conn)?; Ok(Self { conn: Arc::new(Mutex::new(conn)), }) @@ -1789,7 +1798,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { r#"INSERT INTO objectives ( workspace_id, objective_id, title, state, body_md, created_at, updated_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) - ON CONFLICT(objective_id) DO UPDATE SET + ON CONFLICT(workspace_id, objective_id) DO UPDATE SET workspace_id = excluded.workspace_id, title = excluded.title, state = excluded.state, @@ -1888,7 +1897,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { r#"INSERT INTO objective_ticket_links ( workspace_id, objective_id, ticket_id, kind, created_at ) VALUES (?1, ?2, ?3, ?4, ?5) - ON CONFLICT(objective_id, ticket_id, kind) DO UPDATE SET + ON CONFLICT(workspace_id, objective_id, ticket_id, kind) DO UPDATE SET workspace_id = excluded.workspace_id, created_at = excluded.created_at"#, params![ @@ -1977,7 +1986,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { r#"INSERT INTO objective_resources ( workspace_id, objective_id, resource_path, body, media_type, created_at, updated_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) - ON CONFLICT(objective_id, resource_path) DO UPDATE SET + ON CONFLICT(workspace_id, objective_id, resource_path) DO UPDATE SET workspace_id = excluded.workspace_id, body = excluded.body, media_type = excluded.media_type, @@ -4960,7 +4969,7 @@ DROP TABLE IF EXISTS flow_instances; } fn scope_repository_identity_by_workspace(conn: &Connection) -> Result<()> { - validate_workspace_repository_references(conn)?; + validate_workspace_resource_references(conn)?; conn.execute_batch( r#" CREATE TABLE repositories_v27 ( @@ -5119,14 +5128,24 @@ CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id Ok(()) } -fn validate_workspace_repository_references(conn: &Connection) -> Result<()> { +fn validate_workspace_resource_references(conn: &Connection) -> Result<()> { + let diagnostics = workspace_resource_reference_diagnostics(conn)?; + if diagnostics.is_empty() { + return Ok(()); + } + Err(Error::Store(format!( + "Workspace resource foreign-key preflight failed:\n- {}", + diagnostics.join("\n- ") + ))) +} + +fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result> { + let mut diagnostics = Vec::new(); for (table, repository_nullable) in [ ("workdir_registry", false), ("artifacts", true), - // `typed_tickets` is owned and migrated by the Ticket component. The control-plane - // migration may reject an already-invalid integrated reference, but must not rebuild - // that component table or claim its schema authority. ("typed_tickets", true), + ("merge_requests", false), ] { if !table_exists(conn, table)? || !column_exists(conn, table, "repository_id")? { continue; @@ -5136,24 +5155,113 @@ fn validate_workspace_repository_references(conn: &Connection) -> Result<()> { } else { "" }; - let sql = format!( - "SELECT child.workspace_id, child.repository_id FROM {table} AS child \ - WHERE {null_filter} NOT EXISTS (\ - SELECT 1 FROM repositories AS repository \ - WHERE repository.workspace_id = child.workspace_id \ - AND repository.repository_id = child.repository_id\ - ) LIMIT 1" - ); - let invalid = conn - .query_row(&sql, [], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - }) - .optional()?; - if let Some((workspace_id, repository_id)) = invalid { - return Err(Error::Store(format!( - "invalid Workspace-owned repository reference: {table} contains repository `{repository_id}` outside Workspace `{workspace_id}`" - ))); + collect_reference_diagnostics( + conn, + &format!( + "SELECT child.workspace_id || '/' || child.repository_id FROM {table} AS child \ + WHERE {null_filter} NOT EXISTS (\ + SELECT 1 FROM repositories AS parent \ + WHERE parent.workspace_id = child.workspace_id \ + AND parent.repository_id = child.repository_id\ + ) LIMIT 100" + ), + &format!("{table}.repository_id"), + &mut diagnostics, + )?; + } + + for (label, sql) in [ + ( + "typed_ticket_relations.target", + "SELECT relation.workspace_id || '/' || relation.ticket_id || ' -> ' || relation.target \ + FROM typed_ticket_relations AS relation \ + WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS target \ + WHERE target.workspace_id = relation.workspace_id \ + AND target.ticket_id = relation.target) LIMIT 100", + ), + ( + "objective_events.objective_id", + "SELECT child.workspace_id || '/' || child.event_id || ' -> ' || child.objective_id \ + FROM objective_events AS child \ + WHERE NOT EXISTS (SELECT 1 FROM objectives AS parent \ + WHERE parent.workspace_id = child.workspace_id \ + AND parent.objective_id = child.objective_id) LIMIT 100", + ), + ( + "objective_resources.objective_id", + "SELECT child.workspace_id || '/' || child.resource_path || ' -> ' || child.objective_id \ + FROM objective_resources AS child \ + WHERE NOT EXISTS (SELECT 1 FROM objectives AS parent \ + WHERE parent.workspace_id = child.workspace_id \ + AND parent.objective_id = child.objective_id) LIMIT 100", + ), + ( + "objective_ticket_links.objective_id", + "SELECT link.workspace_id || '/' || link.objective_id || ' -> ' || link.ticket_id \ + FROM objective_ticket_links AS link \ + WHERE NOT EXISTS (SELECT 1 FROM objectives AS objective \ + WHERE objective.workspace_id = link.workspace_id \ + AND objective.objective_id = link.objective_id) LIMIT 100", + ), + ( + "objective_ticket_links.ticket_id", + "SELECT link.workspace_id || '/' || link.objective_id || ' -> ' || link.ticket_id \ + FROM objective_ticket_links AS link \ + WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \ + WHERE ticket.workspace_id = link.workspace_id \ + AND ticket.ticket_id = link.ticket_id) LIMIT 100", + ), + ( + "artifacts.ticket_id", + "SELECT artifact.workspace_id || '/' || artifact.artifact_id || ' -> ' || artifact.ticket_id \ + FROM artifacts AS artifact WHERE artifact.ticket_id IS NOT NULL \ + AND NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \ + WHERE ticket.workspace_id = artifact.workspace_id \ + AND ticket.ticket_id = artifact.ticket_id) LIMIT 100", + ), + ( + "artifacts.worker_ref", + "SELECT artifact.workspace_id || '/' || artifact.artifact_id \ + FROM artifacts AS artifact \ + WHERE (artifact.worker_ref_kind IS NULL) != (artifact.worker_ref_key IS NULL) LIMIT 100", + ), + ( + "artifacts.objective_id", + "SELECT artifact.workspace_id || '/' || artifact.artifact_id || ' -> ' || artifact.objective_id \ + FROM artifacts AS artifact WHERE artifact.objective_id IS NOT NULL \ + AND NOT EXISTS (SELECT 1 FROM objectives AS objective \ + WHERE objective.workspace_id = artifact.workspace_id \ + AND objective.objective_id = artifact.objective_id) LIMIT 100", + ), + ] { + let Some(table) = label.split('.').next() else { + continue; + }; + if !table_exists(conn, table)? { + continue; } + if sql.contains("typed_tickets") && !table_exists(conn, "typed_tickets")? { + continue; + } + if sql.contains("worker.worker_id") && !column_exists(conn, "worker_registry", "worker_id")? + { + continue; + } + collect_reference_diagnostics(conn, sql, label, &mut diagnostics)?; + } + Ok(diagnostics) +} + +fn collect_reference_diagnostics( + conn: &Connection, + sql: &str, + label: &str, + diagnostics: &mut Vec, +) -> Result<()> { + let mut statement = conn.prepare(sql)?; + let rows = statement.query_map([], |row| row.get::<_, String>(0))?; + for row in rows { + diagnostics.push(format!("{label}: {}", row?)); } Ok(()) } @@ -5934,11 +6042,384 @@ pub(crate) fn materialize_main_config_entrypoint(conn: &Connection) -> Result<() Ok(()) } +fn enforce_workspace_resource_foreign_keys(conn: &Connection) -> Result<()> { + let schema = r#" +CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_registry_workspace_runtime_worker + ON worker_registry(workspace_id, runtime_id, worker_id); + +CREATE TABLE objectives_v39 ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + title TEXT NOT NULL, + state TEXT NOT NULL, + body_md TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, objective_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +INSERT INTO objectives_v39 SELECT * FROM objectives; + +CREATE TABLE objective_events_v39 ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + event_id TEXT NOT NULL, + kind TEXT NOT NULL, + body_md TEXT, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, event_id), + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE +); +INSERT INTO objective_events_v39 +SELECT workspace_id, objective_id, event_id, kind, body_md, created_at FROM objective_events; + +CREATE TABLE objective_resources_v39 ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + resource_path TEXT NOT NULL, + body TEXT NOT NULL, + media_type TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, objective_id, resource_path), + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE +); +INSERT INTO objective_resources_v39 +SELECT workspace_id, objective_id, resource_path, body, media_type, created_at, updated_at +FROM objective_resources; + +CREATE TABLE typed_tickets_v39 ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + slug TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL, + kind TEXT NOT NULL, + priority TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT, + updated_at TEXT, + assignee TEXT, + readiness TEXT, + workflow_state TEXT NOT NULL, + workflow_state_explicit INTEGER NOT NULL, + queued_by TEXT, + queued_at TEXT, + resolution TEXT, + repository_id TEXT, + ref_selector TEXT, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_id) + REFERENCES repositories(workspace_id, repository_id) ON DELETE RESTRICT +); +INSERT INTO typed_tickets_v39 SELECT * FROM typed_tickets; + +CREATE TABLE typed_ticket_relations_v39 ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + kind TEXT NOT NULL, + target TEXT NOT NULL, + note TEXT, + author TEXT NOT NULL, + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, kind, target), + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, target) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +INSERT INTO typed_ticket_relations_v39 SELECT * FROM typed_ticket_relations; + +CREATE TABLE objective_ticket_links_v39 ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + kind TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, objective_id, ticket_id, kind), + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +INSERT INTO objective_ticket_links_v39 SELECT * FROM objective_ticket_links; + +CREATE TABLE ticket_worker_assignments_v39 ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + assignment_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + assigned_by TEXT NOT NULL, + assigned_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, assignment_id), + UNIQUE (workspace_id, ticket_id, assignment_id) +); +INSERT INTO ticket_worker_assignments_v39 SELECT * FROM ticket_worker_assignments; + +CREATE TABLE ticket_worker_assignment_events_v39 ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + event_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('assigned', 'reassigned', 'unassigned')), + assignment_id TEXT, + previous_assignment_id TEXT, + actor TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, event_id) +); +INSERT INTO ticket_worker_assignment_events_v39 SELECT * FROM ticket_worker_assignment_events; + +CREATE TABLE ticket_current_worker_assignments_v39 ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + assignment_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id), + UNIQUE (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id, ticket_id, assignment_id) + REFERENCES ticket_worker_assignments(workspace_id, ticket_id, assignment_id) + ON DELETE CASCADE +); +INSERT INTO ticket_current_worker_assignments_v39 SELECT * FROM ticket_current_worker_assignments; + +CREATE TABLE ticket_assignment_operations_v39 ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('assign', 'reassign', 'unassign')), + ticket_id TEXT NOT NULL, + runtime_id TEXT, + worker_id TEXT, + assignment_id TEXT, + expected_assignment_id TEXT, + created_at TEXT NOT NULL, + request_fingerprint TEXT, + PRIMARY KEY (workspace_id, operation_id) +); +INSERT INTO ticket_assignment_operations_v39 SELECT * FROM ticket_assignment_operations; + +CREATE TABLE artifacts_v39 ( + workspace_id TEXT NOT NULL, + artifact_id TEXT NOT NULL, + kind TEXT NOT NULL, + uri TEXT NOT NULL, + media_type TEXT, + sha256 TEXT, + size_bytes INTEGER, + summary TEXT, + created_at TEXT NOT NULL, + created_by_kind TEXT NOT NULL, + created_by_key TEXT NOT NULL, + created_by_display TEXT NOT NULL, + created_by_source_kind TEXT, + created_by_source_key TEXT, + ticket_id TEXT, + objective_id TEXT, + event_id TEXT, + worker_ref_kind TEXT, + worker_ref_key TEXT, + worker_display TEXT, + repository_id TEXT, + source_kind TEXT, + source_revision TEXT, + PRIMARY KEY (workspace_id, artifact_id), + CHECK ((worker_ref_kind IS NULL) = (worker_ref_key IS NULL)), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_id) + REFERENCES repositories(workspace_id, repository_id) ON DELETE RESTRICT +); +INSERT INTO artifacts_v39 SELECT * FROM artifacts; + +CREATE TABLE workspace_resource_human_key_counters_v39 ( + workspace_id TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')), + next_sequence INTEGER NOT NULL CHECK (next_sequence > 0), + PRIMARY KEY (workspace_id, resource_kind), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +INSERT INTO workspace_resource_human_key_counters_v39 SELECT * FROM workspace_resource_human_key_counters; + +CREATE TABLE workspace_resource_human_keys_v39 ( + workspace_id TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')), + resource_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence > 0), + human_key TEXT NOT NULL, + allocated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, resource_kind, resource_id), + UNIQUE (workspace_id, resource_kind, sequence), + UNIQUE (workspace_id, human_key), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +INSERT INTO workspace_resource_human_keys_v39 SELECT * FROM workspace_resource_human_keys; + +DROP TABLE ticket_current_worker_assignments; +DROP TABLE ticket_worker_assignment_events; +DROP TABLE ticket_assignment_operations; +DROP TABLE objective_ticket_links; +DROP TABLE objective_events; +DROP TABLE objective_resources; +DROP TABLE typed_ticket_relations; + +ALTER TABLE ticket_worker_assignments RENAME TO ticket_worker_assignments_v38; +ALTER TABLE typed_tickets RENAME TO typed_tickets_v38; +ALTER TABLE objectives RENAME TO objectives_v38; + +ALTER TABLE objectives_v39 RENAME TO objectives; +ALTER TABLE typed_tickets_v39 RENAME TO typed_tickets; +ALTER TABLE ticket_worker_assignments_v39 RENAME TO ticket_worker_assignments; +ALTER TABLE objective_events_v39 RENAME TO objective_events; +ALTER TABLE objective_resources_v39 RENAME TO objective_resources; +ALTER TABLE typed_ticket_relations_v39 RENAME TO typed_ticket_relations; +ALTER TABLE objective_ticket_links_v39 RENAME TO objective_ticket_links; +ALTER TABLE ticket_worker_assignment_events_v39 RENAME TO ticket_worker_assignment_events; +ALTER TABLE ticket_current_worker_assignments_v39 RENAME TO ticket_current_worker_assignments; +ALTER TABLE ticket_assignment_operations_v39 RENAME TO ticket_assignment_operations; + +DROP TABLE ticket_worker_assignments_v38; +DROP TABLE typed_tickets_v38; +DROP TABLE objectives_v38; + +DROP TABLE artifacts; +ALTER TABLE artifacts_v39 RENAME TO artifacts; +DROP TABLE workspace_resource_human_keys; +DROP TABLE workspace_resource_human_key_counters; +ALTER TABLE workspace_resource_human_key_counters_v39 RENAME TO workspace_resource_human_key_counters; +ALTER TABLE workspace_resource_human_keys_v39 RENAME TO workspace_resource_human_keys; + +CREATE INDEX IF NOT EXISTS idx_objectives_workspace_updated + ON objectives(workspace_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_objective_events_workspace_created + ON objective_events(workspace_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_objective_resources_workspace_objective + ON objective_resources(workspace_id, objective_id); +CREATE INDEX IF NOT EXISTS idx_objective_ticket_links_workspace_objective + ON objective_ticket_links(workspace_id, objective_id); +CREATE INDEX IF NOT EXISTS idx_objective_ticket_links_workspace_ticket + ON objective_ticket_links(workspace_id, ticket_id); +CREATE INDEX IF NOT EXISTS idx_typed_tickets_workspace_state_updated + ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id); +CREATE INDEX IF NOT EXISTS idx_typed_tickets_workspace_updated + ON typed_tickets(workspace_id, updated_at DESC, ticket_id); +CREATE INDEX IF NOT EXISTS idx_typed_ticket_relations_workspace_target + ON typed_ticket_relations(workspace_id, target, at DESC); +CREATE INDEX IF NOT EXISTS idx_ticket_worker_assignments_ticket + ON ticket_worker_assignments(workspace_id, ticket_id, assigned_at DESC); +CREATE INDEX IF NOT EXISTS idx_ticket_worker_assignments_worker + ON ticket_worker_assignments(workspace_id, runtime_id, worker_id, assigned_at DESC); +CREATE INDEX IF NOT EXISTS idx_ticket_worker_assignment_events_ticket + ON ticket_worker_assignment_events(workspace_id, ticket_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_ticket_assignment_operations_ticket + ON ticket_assignment_operations(workspace_id, ticket_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_artifacts_workspace_created + ON artifacts(workspace_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_workspace_resource_human_keys_reverse + ON workspace_resource_human_keys(workspace_id, resource_kind, human_key); +"#; + for statement in schema + .split(';') + .map(str::trim) + .filter(|sql| !sql.is_empty()) + { + conn.execute_batch(statement).map_err(|error| { + Error::Store(format!( + "Workspace resource FK migration statement failed: {statement}: {error}" + )) + })?; + } + Ok(()) +} + pub(crate) fn apply_migrations_through(conn: &Connection, through_version: i64) -> Result<()> { let current = current_schema_version(conn)?; for migration in MIGRATIONS.iter().filter(|migration| { i64::from(migration.version) > current && i64::from(migration.version) <= through_version }) { + if migration.version == 39 { + ticket::migrate_sqlite_ticket_schema(conn).map_err(|error| { + Error::Store(format!( + "migration 39 Ticket schema preparation failed: {error}" + )) + })?; + if !table_exists(conn, "typed_tickets")? { + return Err(Error::Store( + "migration 39 Ticket schema preparation created no typed_tickets".to_string(), + )); + } + merge_request::migrate(conn).map_err(|error| { + Error::Store(format!( + "migration 39 Merge Request schema preparation failed: {error}" + )) + })?; + validate_workspace_resource_references(conn) + .map_err(|error| Error::Store(format!("migration 39 preflight failed: {error}")))?; + conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")?; + let result = (|| -> Result<()> { + let tx = conn.unchecked_transaction()?; + (migration.apply)(&tx)?; + if !table_exists(&tx, "typed_tickets")? { + return Err(Error::Store( + "migration 39 did not materialize `typed_tickets`".to_string(), + )); + } + let dangling_foreign_key: Option<(String, String)> = tx + .query_row( + "SELECT name, sql FROM sqlite_schema \ + WHERE type = 'table' AND (sql LIKE '%_v38%' OR sql LIKE '%_v39%') LIMIT 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if let Some((child, sql)) = dangling_foreign_key { + return Err(Error::Store(format!( + "migration 39 left a temporary reference in `{child}`: {sql}" + ))); + } + let foreign_key_failures: i64 = tx + .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + }) + .map_err(|error| { + Error::Store(format!( + "migration 39 could not evaluate foreign keys: {error}" + )) + })?; + if foreign_key_failures != 0 { + return Err(Error::Store(format!( + "migration 39 found {foreign_key_failures} foreign key violation(s)" + ))); + } + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![migration.version, migration.name], + ) + .map_err(|error| { + Error::Store(format!("migration 39 version insert failed: {error}")) + })?; + tx.commit().map_err(|error| { + Error::Store(format!("migration 39 commit failed: {error}")) + })?; + Ok(()) + })(); + conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;") + .map_err(|error| { + Error::Store(format!( + "migration 39 could not restore FK enforcement: {error}" + )) + })?; + result?; + continue; + } + let tx = conn.unchecked_transaction()?; if migration.version == 37 { crate::retention::repair_worker_diagnostics_archive_table(&tx)?; @@ -5954,7 +6435,14 @@ pub(crate) fn apply_migrations_through(conn: &Connection, through_version: i64) } fn apply_migrations(conn: &Connection) -> Result<()> { - apply_migrations_through(conn, i64::MAX) + let latest = i64::from(MIGRATIONS.last().expect("at least one migration").version); + let current = current_schema_version(conn)?; + if current > latest { + return Err(Error::Store(format!( + "database schema version {current} is newer than this server supports ({latest}); refusing to serve with an older binary" + ))); + } + apply_migrations_through(conn, latest) } fn align_legacy_bootstrap_schema(conn: &Connection) -> Result<()> { @@ -6443,7 +6931,7 @@ mod tests { let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); apply_migrations(&conn).unwrap(); - assert!(!table_exists(&conn, "ticket_schema_migrations").unwrap()); + assert!(table_exists(&conn, "ticket_schema_migrations").unwrap()); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); store @@ -6482,7 +6970,7 @@ mod tests { let before = std::fs::read(&path).unwrap(); let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); assert_eq!(plan.current_schema_version, 36); - assert_eq!(plan.target_schema_version, 38); + assert_eq!(plan.target_schema_version, 39); assert!(plan.migration_required); assert_eq!(plan.worker_count, 1); assert_eq!(plan.mappings[0].legacy_worker_id, 7); @@ -6496,7 +6984,7 @@ mod tests { store .with_conn(|conn| { assert!(table_exists(conn, "worker_diagnostics_archives")?); - assert_eq!(current_schema_version(conn)?, 38); + assert_eq!(current_schema_version(conn)?, 39); Ok(()) }) .unwrap(); @@ -6575,7 +7063,7 @@ mod tests { ), ] ); - assert_eq!(current_schema_version(&conn).unwrap(), 38); + assert_eq!(current_schema_version(&conn).unwrap(), 39); let foreign_key_error: Option = conn .query_row("PRAGMA foreign_key_check", [], |row| row.get(0)) .optional() @@ -6647,11 +7135,16 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); .unwrap(); tx.commit().unwrap(); } + ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); conn.execute_batch( r#" INSERT INTO workspaces ( workspace_id, display_name, state, created_at, updated_at ) VALUES ('workspace-a', 'Workspace A', 'active', '1', '1'); +INSERT INTO typed_tickets ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + workflow_state, workflow_state_explicit +) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'Ticket A', 'open', 'task', 'normal', '', 'planning', 1); INSERT INTO worker_registry ( workspace_id, runtime_id, runtime_worker_id, display_name, retention_state, created_at, updated_at @@ -6699,7 +7192,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 38); + assert_eq!(current_schema_version(&conn).unwrap(), 39); assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap()); let controller_worker_id: String = conn .query_row( @@ -6817,7 +7310,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 38); + assert_eq!(current_schema_version(&conn).unwrap(), 39); assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); } @@ -6850,7 +7343,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 38); + assert_eq!(current_schema_version(&conn).unwrap(), 39); assert!(table_exists(&conn, "flow_sources").unwrap()); assert!(table_exists(&conn, "flow_source_revisions").unwrap()); assert!(!table_exists(&conn, "flow_instances").unwrap()); @@ -6917,7 +7410,7 @@ INSERT INTO worker_workdir_attachment_reservations ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 38); + assert_eq!(current_schema_version(&conn).unwrap(), 39); let repositories_sql: String = conn .query_row( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'", @@ -7038,8 +7531,7 @@ INSERT INTO workdir_registry ( } #[tokio::test] - async fn startup_rejects_cross_workspace_ticket_repository_reference_without_claiming_ticket_schema() - { + async fn workspace_schema_rejects_cross_workspace_ticket_repository_reference_at_write_time() { let dir = tempfile::tempdir().unwrap(); let database_path = dir.path().join("workspace.sqlite"); let store = SqliteWorkspaceStore::open(&database_path).unwrap(); @@ -7080,15 +7572,14 @@ INSERT INTO workdir_registry ( .unwrap(); let mut input = ticket::NewTicket::new("Foreign repository"); input.repository_id = Some("main".to_string()); - ticket::TicketBackend::create(&backend, input).unwrap(); + let error = ticket::TicketBackend::create(&backend, input).unwrap_err(); + assert!( + error.to_string().contains("FOREIGN KEY constraint failed"), + "{error}" + ); drop(backend); - let error = match SqliteWorkspaceStore::open(&database_path) { - Ok(_) => panic!("cross-Workspace Ticket repository reference must fail closed"), - Err(error) => error, - }; - assert!(error.to_string().contains("typed_tickets")); - assert!(error.to_string().contains("workspace-b")); + SqliteWorkspaceStore::open(&database_path).unwrap(); } #[tokio::test] @@ -7097,7 +7588,7 @@ INSERT INTO workdir_registry ( let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 38); + assert_eq!(store.schema_version().await.unwrap(), 39); assert!( !store .with_conn(|conn| table_exists(conn, "worker_workspace_credentials")) @@ -7114,7 +7605,7 @@ INSERT INTO workdir_registry ( store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 38); + assert_eq!(reopened.schema_version().await.unwrap(), 39); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) @@ -7340,6 +7831,29 @@ INSERT INTO workdir_registry ( }) .await .unwrap(); + store + .with_conn(|conn| { + conn.execute_batch( + r#" +INSERT INTO typed_tickets ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + workflow_state, workflow_state_explicit +) VALUES + ('workspace-a', 'ticket-1', 'ticket-1', 'Ticket 1', 'open', 'task', 'normal', '', 'planning', 1), + ('workspace-a', 'ticket-2', 'ticket-2', 'Ticket 2', 'open', 'task', 'normal', '', 'planning', 1), + ('workspace-a', 'ticket-3', 'ticket-3', 'Ticket 3', 'open', 'task', 'normal', '', 'planning', 1); +INSERT INTO worker_registry ( + workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at +) VALUES + ('workspace-a', 'runtime-1', 'worker-1', 'Worker 1', 'normal', '1', '1'), + ('workspace-a', 'runtime-1', 'worker-other', 'Other Worker', 'normal', '1', '1'), + ('workspace-a', 'runtime-2', 'worker-2', 'Worker 2', 'normal', '1', '1'), + ('workspace-a', 'runtime-3', 'worker-3', 'Worker 3', 'normal', '1', '1'); +"#, + )?; + Ok(()) + }) + .unwrap(); let first = TicketWorkerAssignmentRecord { workspace_id: "workspace-a".to_string(), @@ -7584,6 +8098,256 @@ INSERT INTO workdir_registry ( ); } + #[test] + fn server_refuses_a_database_from_a_newer_schema_generation() { + let conn = Connection::open_in_memory().unwrap(); + configure_sqlite(&conn).unwrap(); + apply_migrations(&conn).unwrap(); + conn.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (40, 'future')", + [], + ) + .unwrap(); + + let error = apply_migrations(&conn).unwrap_err().to_string(); + assert!(error.contains("schema version 40 is newer"), "{error}"); + assert!(error.contains("refusing to serve"), "{error}"); + } + + #[test] + fn migration_plan_lists_workspace_reference_violations_without_mutating_source() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server.db"); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + apply_migrations_through(&conn, 38).unwrap(); + ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); + merge_request::migrate(&conn).unwrap(); + conn.execute_batch( + r#" +INSERT INTO workspaces (workspace_id, display_name, state, created_at, updated_at) VALUES + ('workspace-a', 'A', 'active', '2026-01-01', '2026-01-01'), + ('workspace-b', 'B', 'active', '2026-01-01', '2026-01-01'); +INSERT INTO typed_tickets ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + workflow_state, workflow_state_explicit +) VALUES + ('workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', '', 'planning', 1), + ('workspace-b', 'ticket-b', 'ticket-b', 'B', 'open', 'task', 'normal', '', 'planning', 1); +INSERT INTO typed_ticket_relations (workspace_id, ticket_id, kind, target, note, author, at) +VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026-01-01'); +"#, + ) + .unwrap(); + drop(conn); + + let error = SqliteWorkspaceStore::migration_plan(&path) + .unwrap_err() + .to_string(); + assert!(error.contains("typed_ticket_relations.target"), "{error}"); + assert!( + error.contains("workspace-b/ticket-b -> ticket-a"), + "{error}" + ); + + let source = Connection::open(&path).unwrap(); + assert_eq!(current_schema_version(&source).unwrap(), 38); + assert_eq!( + source + .query_row("SELECT COUNT(*) FROM typed_ticket_relations", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 1 + ); + } + + #[test] + fn workspace_resource_fk_migration_rolls_back_constraint_failures() { + let conn = Connection::open_in_memory().unwrap(); + configure_sqlite(&conn).unwrap(); + apply_migrations_through(&conn, 38).unwrap(); + ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); + merge_request::migrate(&conn).unwrap(); + conn.execute( + "INSERT INTO workspaces (workspace_id, display_name, state, created_at, updated_at) \ + VALUES ('workspace-a', 'A', 'active', '2026-01-01', '2026-01-01')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, \ + workflow_state, workflow_state_explicit, repository_id, ref_selector) \ + VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', '', \ + 'planning', 1, NULL, 'develop')", + [], + ) + .unwrap(); + + conn.execute_batch("CREATE TABLE typed_tickets_v39 (sentinel TEXT)") + .unwrap(); + + let error = apply_migrations_through(&conn, 39).unwrap_err().to_string(); + assert!(error.contains("CREATE TABLE typed_tickets_v39"), "{error}"); + assert_eq!(current_schema_version(&conn).unwrap(), 38); + assert_eq!( + conn.query_row( + "SELECT ref_selector FROM typed_tickets WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "develop" + ); + assert!(table_exists(&conn, "typed_tickets_v39").unwrap()); + assert!(!table_exists(&conn, "objectives_v39").unwrap()); + let foreign_keys_enabled: i64 = conn + .query_row("PRAGMA foreign_keys", [], |row| row.get(0)) + .unwrap(); + assert_eq!(foreign_keys_enabled, 1); + } + + #[test] + fn workspace_resource_fk_migration_preflights_and_enforces_composite_identity() { + let conn = Connection::open_in_memory().unwrap(); + configure_sqlite(&conn).unwrap(); + apply_migrations_through(&conn, 38).unwrap(); + ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); + merge_request::migrate(&conn).unwrap(); + + conn.execute_batch( + r#" +INSERT INTO workspaces ( + workspace_id, display_name, state, created_at, updated_at +) VALUES + ('workspace-a', 'A', 'active', '2026-01-01', '2026-01-01'), + ('workspace-b', 'B', 'active', '2026-01-01', '2026-01-01'); +INSERT INTO repositories ( + workspace_id, repository_id, name, kind, provider, uri, default_ref, created_at, updated_at +) VALUES ('workspace-a', 'repo-a', 'Repo A', 'git', 'git', '/repo-a', 'develop', '2026-01-01', '2026-01-01'); +INSERT INTO typed_tickets ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + created_at, updated_at, workflow_state, workflow_state_explicit, + repository_id, ref_selector +) VALUES + ('workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', '', + '2026-01-01', '2026-01-01', 'planning', 1, 'repo-a', 'develop'), + ('workspace-b', 'ticket-b', 'ticket-b', 'B', 'open', 'task', 'normal', '', + '2026-01-01', '2026-01-01', 'planning', 1, NULL, NULL); +INSERT INTO objectives ( + workspace_id, objective_id, title, state, body_md, created_at, updated_at +) VALUES ('workspace-a', 'objective-a', 'A', 'active', '', '2026-01-01', '2026-01-01'); +INSERT INTO typed_ticket_relations ( + workspace_id, ticket_id, kind, target, note, author, at +) VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026-01-01'); +INSERT INTO objective_ticket_links ( + workspace_id, objective_id, ticket_id, kind, created_at +) VALUES ('workspace-b', 'objective-a', 'ticket-b', 'tracks', '2026-01-01'); +"#, + ) + .unwrap(); + + let error = apply_migrations_through(&conn, 39).unwrap_err().to_string(); + assert!(error.contains("typed_ticket_relations.target"), "{error}"); + assert!( + error.contains("workspace-b/ticket-b -> ticket-a"), + "{error}" + ); + assert!( + error.contains("objective_ticket_links.objective_id"), + "{error}" + ); + assert_eq!(current_schema_version(&conn).unwrap(), 38); + + conn.execute("DELETE FROM typed_ticket_relations", []) + .unwrap(); + conn.execute("DELETE FROM objective_ticket_links", []) + .unwrap(); + apply_migrations_through(&conn, 39).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 39); + + let bad_target = conn.execute( + "UPDATE typed_tickets SET repository_id = 'repo-a', ref_selector = 'develop' \ + WHERE workspace_id = 'workspace-b' AND ticket_id = 'ticket-b'", + [], + ); + assert!(bad_target.is_err()); + let cross_relation = conn.execute( + "INSERT INTO typed_ticket_relations \ + (workspace_id, ticket_id, kind, target, note, author, at) \ + VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026-01-01')", + [], + ); + assert!(cross_relation.is_err()); + let cross_objective_link = conn.execute( + "INSERT INTO objective_ticket_links \ + (workspace_id, objective_id, ticket_id, kind, created_at) \ + VALUES ('workspace-b', 'objective-a', 'ticket-b', 'tracks', '2026-01-01')", + [], + ); + assert!(cross_objective_link.is_err()); + assert!( + conn.execute( + "DELETE FROM repositories WHERE workspace_id = 'workspace-a' AND repository_id = 'repo-a'", + [], + ) + .is_err() + ); + conn.execute( + "INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, \ + workflow_state, workflow_state_explicit) VALUES \ + ('workspace-b', 'ticket-b2', 'ticket-b2', 'B2', 'open', 'task', 'normal', '', 'planning', 1)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO typed_ticket_relations \ + (workspace_id, ticket_id, kind, target, note, author, at) \ + VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-b2', NULL, 'tester', '2026-01-01')", + [], + ) + .unwrap(); + conn.execute( + "DELETE FROM typed_tickets WHERE workspace_id = 'workspace-b' AND ticket_id = 'ticket-b2'", + [], + ) + .unwrap(); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM typed_ticket_relations WHERE workspace_id = 'workspace-b'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0 + ); + conn.execute( + "DELETE FROM workspaces WHERE workspace_id = 'workspace-b'", + [], + ) + .unwrap(); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM typed_tickets WHERE workspace_id = 'workspace-b'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0 + ); + + let foreign_key_failures: i64 = conn + .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(foreign_key_failures, 0); + let integrity: String = conn + .query_row("PRAGMA integrity_check", [], |row| row.get(0)) + .unwrap(); + assert_eq!(integrity, "ok"); + } + #[test] fn fresh_schema_matches_workspace_db_v0_boundaries() { let conn = Connection::open_in_memory().unwrap(); @@ -7790,7 +8554,7 @@ INSERT INTO workdir_registry ( .unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 38); + assert_eq!(store.schema_version().await.unwrap(), 39); store .with_conn(|conn| { @@ -7979,7 +8743,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn repository_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 38); + assert_eq!(store.schema_version().await.unwrap(), 39); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -8045,7 +8809,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn memory_authority_records_round_trip_and_close_staging() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 38); + assert_eq!(store.schema_version().await.unwrap(), 39); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -8436,7 +9200,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn account_and_login_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 38); + assert_eq!(store.schema_version().await.unwrap(), 39); let now = "2026-07-22T00:00:00Z".to_string(); let account = AccountRecord { account_id: "acct-user-alice".to_string(),