From 223a6ed011bd2a66c58ea8bfb9c52758fc9441c6 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 10:42:57 +0900 Subject: [PATCH 1/7] 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(), From 4583b512b3bd336d6382bf0f8009779cb69fcb0c Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 10:43:05 +0900 Subject: [PATCH 2/7] docs: add workspace schema migration runbook --- docs/README.md | 1 + .../workspace-schema-migrations.md | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 docs/development/workspace-schema-migrations.md diff --git a/docs/README.md b/docs/README.md index ba2ced5f..4cd18397 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ It is not a dumping ground for external research, old plans, API inventories, or 14. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed. 15. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them. 16. [`development/validation.md`](development/validation.md) — how to check changes. +17. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes. ## What belongs here diff --git a/docs/development/workspace-schema-migrations.md b/docs/development/workspace-schema-migrations.md new file mode 100644 index 00000000..5e878942 --- /dev/null +++ b/docs/development/workspace-schema-migrations.md @@ -0,0 +1,41 @@ +# Workspace database schema migration runbook + +The Workspace Server owns one control-plane SQLite database. Schema changes are applied by the Server at startup; domain components such as Ticket and Merge Request contribute tables to that same database, but they do not create a second Workspace authority. + +## Before deployment + +1. Stop writes and shut down every Server process using the database. Do not run two Server generations against one database during migration. +2. Record the current binary revision and database schema version. +3. Take a byte-for-byte backup of the database and its WAL/SHM state using a SQLite-safe backup procedure. +4. Run the read-only plan with the new binary: + + ```sh + yoi-server migrate --dry-run --database + ``` + + The plan runs against an in-memory copy. It reports the current and target schema versions, migration names, Worker identity mappings, and repairs without mutating the source database. Workspace-resource preflight failures name the relation and bounded offending row identities; repair those rows through the owning domain authority before retrying. + +## Applying + +Start exactly one instance of the new Server binary against the database. Startup applies migration 39 in one SQLite transaction after the Ticket and Merge Request component schemas are available. The migration: + +- rebuilds Ticket, Objective, assignment, Artifact, and human-key tables with Workspace-scoped composite identity; +- adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and assignment references; +- checks the rebuilt schema with `PRAGMA foreign_key_check` before recording the schema version; and +- restores `PRAGMA foreign_keys = ON` whether the transaction commits or rolls back. + +After startup, verify: + +```sql +SELECT MAX(version) FROM __yoi_schema_migrations; +PRAGMA foreign_key_check; +PRAGMA integrity_check; +``` + +The expected migration version is `39`, `foreign_key_check` returns no rows, and `integrity_check` returns `ok`. + +## Failure and rollback + +There is no in-place down migration. A failed migration transaction leaves the prior schema version and data intact. Keep the Server stopped, preserve the failure diagnostics, and either repair the preflight data with the prior generation or restore the complete pre-migration backup before retrying. + +Never run an older binary after a newer schema version has committed. Startup fences this case and refuses to serve when the database schema version is newer than the binary supports. Rollback therefore means restoring both the prior binary and its matching pre-migration database backup; it does not mean pointing the old binary at the upgraded database. From 560226dea2af1996a2fadaa4eaf053a0a30b09d6 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 11:06:31 +0900 Subject: [PATCH 3/7] fix: enforce assignment workspace references --- crates/workspace-server/src/retention.rs | 9 + crates/workspace-server/src/server.rs | 51 ++++ crates/workspace-server/src/store.rs | 287 +++++++++++++++++- .../workspace-schema-migrations.md | 3 +- 4 files changed, 343 insertions(+), 7 deletions(-) diff --git a/crates/workspace-server/src/retention.rs b/crates/workspace-server/src/retention.rs index 3b34a6db..58d2baf8 100644 --- a/crates/workspace-server/src/retention.rs +++ b/crates/workspace-server/src/retention.rs @@ -1082,6 +1082,11 @@ mod tests { ) VALUES('w',?1,'r','one','builtin:coder','normal','created','rev1')", [worker_id().to_string()], )?; + c.execute( + "INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit) \ + VALUES ('w', 'ticket', 'ticket', 'Ticket', 'open', 'task', 'normal', '', 'planning', 1)", + [], + )?; Ok(()) }) .unwrap(); @@ -1267,7 +1272,11 @@ mod tests { fn purge_tombstone_commit_is_idempotent() { let s = setup(); s.with_conn(|conn| { + conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','','planning',1)", [])?; + conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('w','1','r','old worker','builtin:coder','normal','created','rev1')", [])?; conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','ticket-old','assignment-old','r','1','test','t')", [])?; + conn.execute("DELETE FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND worker_id='1'", [])?; + conn.execute("DELETE FROM typed_tickets WHERE workspace_id='w' AND ticket_id='ticket-old'", [])?; Ok(()) }).unwrap(); let p = s.plan_worker_removal(&req(), &inv()).unwrap(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 7cdfac14..578ad3d0 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -14556,6 +14556,21 @@ mod tests { .create(ticket::NewTicket::new("Assigned Ticket")) .unwrap(); let ticket_id = created.id; + api.store + .upsert_worker_registry(&WorkerRegistryRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + worker: RuntimeWorkerRef::new("embedded", "42"), + display_name: "Worker 42".to_string(), + profile: Some("builtin:coder".to_string()), + retention_state: "normal".to_string(), + transcript_ref: None, + session_ref: None, + summary_ref: None, + diagnostics_ref: None, + created_at: TEST_CREATED_AT.to_string(), + updated_at: TEST_CREATED_AT.to_string(), + }) + .unwrap(); let assignment = TicketWorkerAssignmentRecord { workspace_id: TEST_WORKSPACE_ID.to_string(), ticket_id: ticket_id.clone(), @@ -14650,6 +14665,24 @@ mod tests { .unwrap() .worker .unwrap(); + api.store + .upsert_worker_registry(&WorkerRegistryRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + worker: RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + source_worker.worker.worker_id.clone(), + ), + display_name: "Source Worker".to_string(), + profile: Some("builtin:coder".to_string()), + retention_state: "normal".to_string(), + transcript_ref: None, + session_ref: None, + summary_ref: None, + diagnostics_ref: None, + created_at: TEST_CREATED_AT.to_string(), + updated_at: TEST_CREATED_AT.to_string(), + }) + .unwrap(); let recipient_worker = api .runtime .spawn_worker( @@ -14660,6 +14693,24 @@ mod tests { .unwrap() .worker .unwrap(); + api.store + .upsert_worker_registry(&WorkerRegistryRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + worker: RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + recipient_worker.worker.worker_id.clone(), + ), + display_name: "Recipient Worker".to_string(), + profile: Some("builtin:coder".to_string()), + retention_state: "normal".to_string(), + transcript_ref: None, + session_ref: None, + summary_ref: None, + diagnostics_ref: None, + created_at: TEST_CREATED_AT.to_string(), + updated_at: TEST_CREATED_AT.to_string(), + }) + .unwrap(); let backend = browser_ticket_backend(&api).unwrap(); let ticket_ref = backend .create(ticket::NewTicket::new("Notify assigned Worker")) diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 742c51a4..8069d018 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -1075,6 +1075,7 @@ impl SqliteWorkspaceStore { Error::Store(format!("Merge Request schema verification failed: {error}")) })?; validate_workspace_resource_references(&conn)?; + verify_workspace_resource_constraints(&conn)?; Ok(Self { conn: Arc::new(Mutex::new(conn)), }) @@ -5128,6 +5129,30 @@ CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id Ok(()) } +fn verify_workspace_resource_constraints(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? < 39 { + return Ok(()); + } + for trigger in [ + "ticket_worker_assignments_validate_insert", + "ticket_worker_assignments_validate_update", + "ticket_worker_assignment_events_validate_insert", + "ticket_assignment_operations_validate_insert", + ] { + let exists = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type = 'trigger' AND name = ?1)", + [trigger], + |row| row.get::<_, bool>(0), + )?; + if !exists { + return Err(Error::Store(format!( + "Workspace resource constraint trigger `{trigger}` is missing" + ))); + } + } + Ok(()) +} + fn validate_workspace_resource_references(conn: &Connection) -> Result<()> { let diagnostics = workspace_resource_reference_diagnostics(conn)?; if diagnostics.is_empty() { @@ -5211,6 +5236,55 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result ' || assignment.ticket_id \ + FROM ticket_worker_assignments AS assignment \ + WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \ + WHERE ticket.workspace_id = assignment.workspace_id \ + AND ticket.ticket_id = assignment.ticket_id) LIMIT 100", + ), + ( + "ticket_worker_assignments.worker_id", + "SELECT assignment.workspace_id || '/' || assignment.assignment_id || ' -> ' || assignment.runtime_id || '/' || assignment.worker_id \ + FROM ticket_worker_assignments AS assignment \ + WHERE NOT EXISTS (SELECT 1 FROM worker_registry AS worker \ + WHERE worker.workspace_id = assignment.workspace_id \ + AND worker.runtime_id = assignment.runtime_id \ + AND worker.worker_id = assignment.worker_id) LIMIT 100", + ), + ( + "ticket_current_worker_assignments.assignment_id", + "SELECT current.workspace_id || '/' || current.assignment_id \ + FROM ticket_current_worker_assignments AS current \ + WHERE NOT EXISTS (SELECT 1 FROM ticket_worker_assignments AS assignment \ + WHERE assignment.workspace_id = current.workspace_id \ + AND assignment.ticket_id = current.ticket_id \ + AND assignment.assignment_id = current.assignment_id \ + AND assignment.runtime_id = current.runtime_id \ + AND assignment.worker_id = current.worker_id) LIMIT 100", + ), + ( + "ticket_worker_assignment_events.assignment_id", + "SELECT event.workspace_id || '/' || event.event_id \ + FROM ticket_worker_assignment_events AS event \ + WHERE (event.assignment_id IS NOT NULL AND NOT EXISTS (\ + SELECT 1 FROM ticket_worker_assignments AS assignment \ + WHERE assignment.workspace_id = event.workspace_id \ + AND assignment.assignment_id = event.assignment_id)) \ + OR (event.previous_assignment_id IS NOT NULL AND NOT EXISTS (\ + SELECT 1 FROM ticket_worker_assignments AS assignment \ + WHERE assignment.workspace_id = event.workspace_id \ + AND assignment.assignment_id = event.previous_assignment_id)) LIMIT 100", + ), + ( + "ticket_assignment_operations.ticket_id", + "SELECT operation.workspace_id || '/' || operation.operation_id || ' -> ' || operation.ticket_id \ + FROM ticket_assignment_operations AS operation \ + WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \ + WHERE ticket.workspace_id = operation.workspace_id \ + AND ticket.ticket_id = operation.ticket_id) LIMIT 100", + ), ( "artifacts.ticket_id", "SELECT artifact.workspace_id || '/' || artifact.artifact_id || ' -> ' || artifact.ticket_id \ @@ -5247,6 +5321,16 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result panic!("missing assignment constraint trigger must fail closed"), + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("ticket_worker_assignments_validate_insert"), + "{error}" + ); + } + #[test] fn migration_plan_lists_workspace_reference_violations_without_mutating_source() { let temp = tempfile::tempdir().unwrap(); @@ -8243,6 +8424,17 @@ INSERT INTO typed_ticket_relations ( 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'); +INSERT INTO worker_registry ( + workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at +) VALUES + ('workspace-a', 'runtime-a', '00000000-0000-7000-8000-000000000001', 'Worker A', 'normal', '2026-01-01', '2026-01-01'), + ('workspace-b', 'runtime-b', '00000000-0000-7000-8000-000000000002', 'Worker B', 'normal', '2026-01-01', '2026-01-01'); +INSERT INTO ticket_worker_assignments ( + workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at +) VALUES ( + 'workspace-b', 'ticket-b', 'assignment-cross-worker', 'runtime-a', + '00000000-0000-7000-8000-000000000001', 'tester', '2026-01-01' +); "#, ) .unwrap(); @@ -8257,12 +8449,19 @@ INSERT INTO objective_ticket_links ( error.contains("objective_ticket_links.objective_id"), "{error}" ); + assert!( + error.contains("ticket_worker_assignments.worker_id"), + "{error}" + ); + assert!(error.contains("assignment-cross-worker"), "{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(); + conn.execute("DELETE FROM ticket_worker_assignments", []) + .unwrap(); apply_migrations_through(&conn, 39).unwrap(); assert_eq!(current_schema_version(&conn).unwrap(), 39); @@ -8286,6 +8485,53 @@ INSERT INTO objective_ticket_links ( [], ); assert!(cross_objective_link.is_err()); + let cross_ticket_assignment = conn.execute( + "INSERT INTO ticket_worker_assignments \ + (workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at) \ + VALUES ('workspace-b', 'ticket-a', 'assignment-cross-ticket', 'runtime-b', \ + '00000000-0000-7000-8000-000000000002', 'tester', '2026-01-01')", + [], + ); + assert!(cross_ticket_assignment.is_err()); + let cross_worker_assignment = conn.execute( + "INSERT INTO ticket_worker_assignments \ + (workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at) \ + VALUES ('workspace-b', 'ticket-b', 'assignment-cross-worker', 'runtime-a', \ + '00000000-0000-7000-8000-000000000001', 'tester', '2026-01-01')", + [], + ); + assert!(cross_worker_assignment.is_err()); + conn.execute( + "INSERT INTO ticket_worker_assignments \ + (workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at) \ + VALUES ('workspace-b', 'ticket-b', 'assignment-b', 'runtime-b', \ + '00000000-0000-7000-8000-000000000002', 'tester', '2026-01-01')", + [], + ) + .unwrap(); + let mismatched_current_assignment = conn.execute( + "INSERT INTO ticket_current_worker_assignments \ + (workspace_id, ticket_id, assignment_id, runtime_id, worker_id, updated_at) \ + VALUES ('workspace-b', 'ticket-b', 'assignment-b', 'runtime-a', \ + '00000000-0000-7000-8000-000000000001', '2026-01-01')", + [], + ); + assert!(mismatched_current_assignment.is_err()); + let cross_assignment_event = conn.execute( + "INSERT INTO ticket_worker_assignment_events \ + (workspace_id, ticket_id, event_id, action, assignment_id, actor, created_at) \ + VALUES ('workspace-a', 'ticket-a', 'event-cross-assignment', 'assigned', \ + 'assignment-b', 'tester', '2026-01-01')", + [], + ); + assert!(cross_assignment_event.is_err()); + let cross_operation_ticket = conn.execute( + "INSERT INTO ticket_assignment_operations \ + (workspace_id, operation_id, action, ticket_id, created_at) \ + VALUES ('workspace-b', 'operation-cross-ticket', 'assign', 'ticket-a', '2026-01-01')", + [], + ); + assert!(cross_operation_ticket.is_err()); assert!( conn.execute( "DELETE FROM repositories WHERE workspace_id = 'workspace-a' AND repository_id = 'repo-a'", @@ -8321,6 +8567,26 @@ INSERT INTO objective_ticket_links ( .unwrap(), 0 ); + conn.execute( + "DELETE FROM worker_registry WHERE workspace_id = 'workspace-b' AND worker_id = '00000000-0000-7000-8000-000000000002'", + [], + ) + .unwrap(); + conn.execute( + "DELETE FROM typed_tickets WHERE workspace_id = 'workspace-b' AND ticket_id = 'ticket-b'", + [], + ) + .unwrap(); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM ticket_worker_assignments WHERE workspace_id = 'workspace-b'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 1, + "historical assignments survive Worker and Ticket retention deletion" + ); conn.execute( "DELETE FROM workspaces WHERE workspace_id = 'workspace-b'", [], @@ -8335,6 +8601,15 @@ INSERT INTO objective_ticket_links ( .unwrap(), 0 ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM ticket_worker_assignments 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| { diff --git a/docs/development/workspace-schema-migrations.md b/docs/development/workspace-schema-migrations.md index 5e878942..e9e2caeb 100644 --- a/docs/development/workspace-schema-migrations.md +++ b/docs/development/workspace-schema-migrations.md @@ -20,7 +20,8 @@ The Workspace Server owns one control-plane SQLite database. Schema changes are Start exactly one instance of the new Server binary against the database. Startup applies migration 39 in one SQLite transaction after the Ticket and Merge Request component schemas are available. The migration: - rebuilds Ticket, Objective, assignment, Artifact, and human-key tables with Workspace-scoped composite identity; -- adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and assignment references; +- adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and current-assignment references; +- validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; reservation operation ids remain intentionally unconstrained until their resources exist; - checks the rebuilt schema with `PRAGMA foreign_key_check` before recording the schema version; and - restores `PRAGMA foreign_keys = ON` whether the transaction commits or rolls back. From 6945b7b3c34e7acb744eb6992e0a95a224f76cc7 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 11:14:33 +0900 Subject: [PATCH 4/7] fix: bind assignment events to tickets --- crates/workspace-server/src/store.rs | 36 +++++++++++++++++++++------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 8069d018..a66e4e80 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -5271,10 +5271,12 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result Date: Thu, 20 Aug 2026 11:21:31 +0900 Subject: [PATCH 5/7] fix: preserve retained assignment history --- crates/workspace-server/src/store.rs | 80 ++++++++++++++++++- .../workspace-schema-migrations.md | 2 +- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index a66e4e80..00243bd9 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -5236,13 +5236,18 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result ' || assignment.ticket_id \ FROM ticket_worker_assignments AS assignment \ WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \ WHERE ticket.workspace_id = assignment.workspace_id \ - AND ticket.ticket_id = assignment.ticket_id) LIMIT 100", + AND ticket.ticket_id = assignment.ticket_id) \ + AND EXISTS (SELECT 1 FROM typed_tickets AS foreign_ticket \ + WHERE foreign_ticket.ticket_id = assignment.ticket_id) LIMIT 100", ), ( "ticket_worker_assignments.worker_id", @@ -5250,8 +5255,9 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result Result Date: Thu, 20 Aug 2026 11:28:41 +0900 Subject: [PATCH 6/7] fix: validate assignment runtime snapshots --- crates/workspace-server/src/store.rs | 25 ++++++++++++++++--- .../workspace-schema-migrations.md | 2 +- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 00243bd9..db1e06cd 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -5237,8 +5237,9 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result ' || assignment.ticket_id \ @@ -5255,9 +5256,10 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result Date: Thu, 20 Aug 2026 11:38:13 +0900 Subject: [PATCH 7/7] fix: track retained assignment parents --- crates/workspace-server/src/store.rs | 220 ++++++++++++++---- .../workspace-schema-migrations.md | 2 +- 2 files changed, 181 insertions(+), 41 deletions(-) diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index db1e06cd..a00fb91d 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -5134,6 +5134,9 @@ fn verify_workspace_resource_constraints(conn: &Connection) -> Result<()> { return Ok(()); } for trigger in [ + "ticket_assignment_ticket_parent_tombstone", + "ticket_assignment_worker_parent_tombstone_delete", + "ticket_assignment_worker_parent_tombstone_move", "ticket_worker_assignments_validate_insert", "ticket_worker_assignments_validate_update", "ticket_worker_assignment_events_validate_insert", @@ -5236,31 +5239,6 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result ' || assignment.ticket_id \ - FROM ticket_worker_assignments AS assignment \ - WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \ - WHERE ticket.workspace_id = assignment.workspace_id \ - AND ticket.ticket_id = assignment.ticket_id) \ - AND EXISTS (SELECT 1 FROM typed_tickets AS foreign_ticket \ - WHERE foreign_ticket.ticket_id = assignment.ticket_id) LIMIT 100", - ), - ( - "ticket_worker_assignments.worker_id", - "SELECT assignment.workspace_id || '/' || assignment.assignment_id || ' -> ' || assignment.runtime_id || '/' || assignment.worker_id \ - FROM ticket_worker_assignments AS assignment \ - WHERE NOT EXISTS (SELECT 1 FROM worker_registry AS worker \ - WHERE worker.workspace_id = assignment.workspace_id \ - AND worker.runtime_id = assignment.runtime_id \ - AND worker.worker_id = assignment.worker_id) \ - AND EXISTS (SELECT 1 FROM worker_registry AS live_worker \ - WHERE live_worker.worker_id = assignment.worker_id) LIMIT 100", - ), ( "ticket_current_worker_assignments.assignment_id", "SELECT current.workspace_id || '/' || current.assignment_id \ @@ -5287,16 +5265,6 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result ' || operation.ticket_id \ - FROM ticket_assignment_operations AS operation \ - WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \ - WHERE ticket.workspace_id = operation.workspace_id \ - AND ticket.ticket_id = operation.ticket_id) \ - AND EXISTS (SELECT 1 FROM typed_tickets AS foreign_ticket \ - WHERE foreign_ticket.ticket_id = operation.ticket_id) LIMIT 100", - ), ( "artifacts.ticket_id", "SELECT artifact.workspace_id || '/' || artifact.artifact_id || ' -> ' || artifact.ticket_id \ @@ -5345,6 +5313,82 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result ' || assignment.ticket_id \ + FROM ticket_worker_assignments AS assignment \ + WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \ + WHERE ticket.workspace_id = assignment.workspace_id \ + AND ticket.ticket_id = assignment.ticket_id) \ + {tombstone_filter} LIMIT 100" + ), + "ticket_worker_assignments.ticket_id", + &mut diagnostics, + )?; + } + if table_exists(conn, "ticket_worker_assignments")? + && table_exists(conn, "worker_registry")? + && column_exists(conn, "ticket_worker_assignments", "worker_id")? + && column_exists(conn, "worker_registry", "worker_id")? + { + let tombstone_filter = if table_exists(conn, "ticket_assignment_worker_tombstones")? { + "AND NOT EXISTS (SELECT 1 FROM ticket_assignment_worker_tombstones AS tombstone \ + WHERE tombstone.workspace_id = assignment.workspace_id \ + AND tombstone.runtime_id = assignment.runtime_id \ + AND tombstone.worker_id = assignment.worker_id)" + } else { + "" + }; + collect_reference_diagnostics( + conn, + &format!( + "SELECT assignment.workspace_id || '/' || assignment.assignment_id || ' -> ' || assignment.runtime_id || '/' || assignment.worker_id \ + FROM ticket_worker_assignments AS assignment \ + WHERE NOT EXISTS (SELECT 1 FROM worker_registry AS worker \ + WHERE worker.workspace_id = assignment.workspace_id \ + AND worker.runtime_id = assignment.runtime_id \ + AND worker.worker_id = assignment.worker_id) \ + {tombstone_filter} LIMIT 100" + ), + "ticket_worker_assignments.worker_id", + &mut diagnostics, + )?; + } + if table_exists(conn, "ticket_assignment_operations")? && table_exists(conn, "typed_tickets")? { + let tombstone_filter = if table_exists(conn, "ticket_assignment_ticket_tombstones")? { + "AND NOT EXISTS (SELECT 1 FROM ticket_assignment_ticket_tombstones AS tombstone \ + WHERE tombstone.workspace_id = operation.workspace_id \ + AND tombstone.ticket_id = operation.ticket_id)" + } else { + "" + }; + collect_reference_diagnostics( + conn, + &format!( + "SELECT operation.workspace_id || '/' || operation.operation_id || ' -> ' || operation.ticket_id \ + FROM ticket_assignment_operations AS operation \ + WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \ + WHERE ticket.workspace_id = operation.workspace_id \ + AND ticket.ticket_id = operation.ticket_id) \ + {tombstone_filter} LIMIT 100" + ), + "ticket_assignment_operations.ticket_id", + &mut diagnostics, + )?; + } Ok(diagnostics) } @@ -6243,6 +6287,23 @@ CREATE TABLE objective_ticket_links_v39 ( ); INSERT INTO objective_ticket_links_v39 SELECT * FROM objective_ticket_links; +CREATE TABLE ticket_assignment_ticket_tombstones ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + deleted_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); + +CREATE TABLE ticket_assignment_worker_tombstones ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + deleted_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); + CREATE TABLE ticket_worker_assignments_v39 ( workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, @@ -6438,13 +6499,61 @@ CREATE INDEX IF NOT EXISTS idx_workspace_resource_human_keys_reverse })?; } // Assignment rows and events are historical evidence and intentionally survive Ticket or - // Worker retention deletion, so parent FKs would impose the wrong delete semantics. These - // triggers provide the equivalent database-layer insertion boundary: every new assignment - // resolves both authorities in the same Workspace, and event references resolve a committed - // assignment for the same Ticket. Operation assignment/Worker ids remain unconstrained because reservations are - // persisted before assignment/Worker creation and expected ids may intentionally be stale. + // Worker retention deletion, so parent FKs would impose the wrong delete semantics. Parent + // delete/move triggers record an exact tombstone before authority disappears; insertion + // triggers require every new assignment to resolve both authorities in the same Workspace, + // and event references resolve a committed assignment for the same Ticket. Operation + // assignment/Worker ids remain unconstrained because reservations are persisted before + // assignment/Worker creation and expected ids may intentionally be stale. conn.execute_batch( r#" +CREATE TRIGGER ticket_assignment_ticket_parent_tombstone +BEFORE DELETE ON typed_tickets +WHEN EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.ticket_id = OLD.ticket_id + ) + OR EXISTS ( + SELECT 1 FROM ticket_assignment_operations AS operation + WHERE operation.workspace_id = OLD.workspace_id + AND operation.ticket_id = OLD.ticket_id + ) +BEGIN + INSERT OR IGNORE INTO ticket_assignment_ticket_tombstones ( + workspace_id, ticket_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.ticket_id, CURRENT_TIMESTAMP); +END; + +CREATE TRIGGER ticket_assignment_worker_parent_tombstone_delete +BEFORE DELETE ON worker_registry +WHEN EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.runtime_id = OLD.runtime_id + AND assignment.worker_id = OLD.worker_id +) +BEGIN + INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( + workspace_id, runtime_id, worker_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); +END; + +CREATE TRIGGER ticket_assignment_worker_parent_tombstone_move +BEFORE UPDATE OF runtime_id ON worker_registry +WHEN OLD.runtime_id != NEW.runtime_id + AND EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.runtime_id = OLD.runtime_id + AND assignment.worker_id = OLD.worker_id +) +BEGIN + INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( + workspace_id, runtime_id, worker_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); +END; + CREATE TRIGGER ticket_worker_assignments_validate_insert BEFORE INSERT ON ticket_worker_assignments WHEN NOT EXISTS ( @@ -8340,6 +8449,18 @@ DELETE FROM worker_registry WHERE workspace_id = 'workspace-a' AND worker_id = '00000000-0000-7000-8000-000000000001'; DELETE FROM typed_tickets WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a'; +INSERT INTO workspaces (workspace_id, display_name, state, created_at, updated_at) +VALUES ('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-b', 'ticket-a', 'ticket-a-b', 'B', '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-b', 'runtime-b', '00000000-0000-7000-8000-000000000001', + 'Worker B', 'normal', '2026-01-01', '2026-01-01' +); "#, )?; Ok(()) @@ -8356,6 +8477,18 @@ WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a'; |row| row.get(0), )?; assert_eq!(retained, 1); + let ticket_tombstones: i64 = conn.query_row( + "SELECT COUNT(*) FROM ticket_assignment_ticket_tombstones WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a'", + [], + |row| row.get(0), + )?; + let worker_tombstones: i64 = conn.query_row( + "SELECT COUNT(*) FROM ticket_assignment_worker_tombstones WHERE workspace_id = 'workspace-a' AND runtime_id = 'runtime-a' AND worker_id = '00000000-0000-7000-8000-000000000001'", + [], + |row| row.get(0), + )?; + assert_eq!(ticket_tombstones, 1); + assert_eq!(worker_tombstones, 1); Ok(()) }) .unwrap(); @@ -8509,6 +8642,12 @@ INSERT INTO ticket_worker_assignments ( 'workspace-b', 'ticket-b', 'assignment-runtime-mismatch', 'runtime-wrong', '00000000-0000-7000-8000-000000000002', 'tester', '2026-01-01' ); +INSERT INTO ticket_worker_assignments ( + workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at +) VALUES ( + 'workspace-b', 'ticket-missing', 'assignment-missing-parents', 'runtime-missing', + '00000000-0000-7000-8000-000000000003', 'tester', '2026-01-01' +); INSERT INTO ticket_worker_assignments ( workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at ) VALUES ( @@ -8552,6 +8691,7 @@ INSERT INTO ticket_worker_assignment_events ( ); assert!(error.contains("assignment-cross-worker"), "{error}"); assert!(error.contains("assignment-runtime-mismatch"), "{error}"); + assert!(error.contains("assignment-missing-parents"), "{error}"); assert!( error.contains("ticket_worker_assignment_events.assignment_id"), "{error}" diff --git a/docs/development/workspace-schema-migrations.md b/docs/development/workspace-schema-migrations.md index 27eabfc1..aac1c97e 100644 --- a/docs/development/workspace-schema-migrations.md +++ b/docs/development/workspace-schema-migrations.md @@ -21,7 +21,7 @@ Start exactly one instance of the new Server binary against the database. Startu - rebuilds Ticket, Objective, assignment, Artifact, and human-key tables with Workspace-scoped composite identity; - adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and current-assignment references; -- validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; startup treats a Ticket or Worker ID missing from every Workspace as retained history, but rejects a Ticket ID in another Workspace and rejects a live Worker ID whose Workspace/Runtime placement does not match the assignment snapshot; reservation operation ids remain intentionally unconstrained until their resources exist; +- validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; parent delete/Runtime-move triggers record exact Workspace-scoped tombstones, and startup accepts a missing live parent only when that tombstone exists, so an unrelated same ID in another Workspace cannot change the result; reservation operation ids remain intentionally unconstrained until their resources exist; - checks the rebuilt schema with `PRAGMA foreign_key_check` before recording the schema version; and - restores `PRAGMA foreign_keys = ON` whether the transaction commits or rolls back.