fix: track retained assignment parents

This commit is contained in:
2026-08-20 11:38:13 +09:00
parent 674b897321
commit d39eb43419
2 changed files with 181 additions and 41 deletions
+180 -40
View File
@@ -5134,6 +5134,9 @@ fn verify_workspace_resource_constraints(conn: &Connection) -> Result<()> {
return Ok(()); return Ok(());
} }
for trigger in [ 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_insert",
"ticket_worker_assignments_validate_update", "ticket_worker_assignments_validate_update",
"ticket_worker_assignment_events_validate_insert", "ticket_worker_assignment_events_validate_insert",
@@ -5236,31 +5239,6 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result<Vec<Str
WHERE ticket.workspace_id = link.workspace_id \ WHERE ticket.workspace_id = link.workspace_id \
AND ticket.ticket_id = link.ticket_id) LIMIT 100", AND ticket.ticket_id = link.ticket_id) LIMIT 100",
), ),
// Historical assignment/operation rows intentionally survive Ticket or Worker
// retention deletion. A parent id missing from every Workspace is therefore a retained
// soft reference; a matching Ticket id in another Workspace, or a live Worker id whose
// Workspace/Runtime placement does not match the snapshot, is corruption.
(
"ticket_worker_assignments.ticket_id",
"SELECT assignment.workspace_id || '/' || assignment.assignment_id || ' -> ' || 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", "ticket_current_worker_assignments.assignment_id",
"SELECT current.workspace_id || '/' || current.assignment_id \ "SELECT current.workspace_id || '/' || current.assignment_id \
@@ -5287,16 +5265,6 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result<Vec<Str
AND assignment.ticket_id = event.ticket_id \ AND assignment.ticket_id = event.ticket_id \
AND assignment.assignment_id = event.previous_assignment_id)) LIMIT 100", 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) \
AND EXISTS (SELECT 1 FROM typed_tickets AS foreign_ticket \
WHERE foreign_ticket.ticket_id = operation.ticket_id) LIMIT 100",
),
( (
"artifacts.ticket_id", "artifacts.ticket_id",
"SELECT artifact.workspace_id || '/' || artifact.artifact_id || ' -> ' || artifact.ticket_id \ "SELECT artifact.workspace_id || '/' || artifact.artifact_id || ' -> ' || artifact.ticket_id \
@@ -5345,6 +5313,82 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result<Vec<Str
} }
collect_reference_diagnostics(conn, sql, label, &mut diagnostics)?; collect_reference_diagnostics(conn, sql, label, &mut diagnostics)?;
} }
// Assignment and operation rows are historical soft references. Schema v39 records an
// explicit tombstone before a live Ticket or Worker parent is deleted/moved; older schemas
// have no tombstone authority, so every missing live parent remains migration-blocking drift.
if table_exists(conn, "ticket_worker_assignments")? && 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 = assignment.workspace_id \
AND tombstone.ticket_id = assignment.ticket_id)"
} else {
""
};
collect_reference_diagnostics(
conn,
&format!(
"SELECT assignment.workspace_id || '/' || assignment.assignment_id || ' -> ' || 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) Ok(diagnostics)
} }
@@ -6243,6 +6287,23 @@ CREATE TABLE objective_ticket_links_v39 (
); );
INSERT INTO objective_ticket_links_v39 SELECT * FROM objective_ticket_links; 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 ( CREATE TABLE ticket_worker_assignments_v39 (
workspace_id TEXT NOT NULL, workspace_id TEXT NOT NULL,
ticket_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 // 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 // Worker retention deletion, so parent FKs would impose the wrong delete semantics. Parent
// triggers provide the equivalent database-layer insertion boundary: every new assignment // delete/move triggers record an exact tombstone before authority disappears; insertion
// resolves both authorities in the same Workspace, and event references resolve a committed // triggers require every new assignment to resolve both authorities in the same Workspace,
// assignment for the same Ticket. Operation assignment/Worker ids remain unconstrained because reservations are // and event references resolve a committed assignment for the same Ticket. Operation
// persisted before assignment/Worker creation and expected ids may intentionally be stale. // assignment/Worker ids remain unconstrained because reservations are persisted before
// assignment/Worker creation and expected ids may intentionally be stale.
conn.execute_batch( conn.execute_batch(
r#" 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 CREATE TRIGGER ticket_worker_assignments_validate_insert
BEFORE INSERT ON ticket_worker_assignments BEFORE INSERT ON ticket_worker_assignments
WHEN NOT EXISTS ( WHEN NOT EXISTS (
@@ -8340,6 +8449,18 @@ DELETE FROM worker_registry
WHERE workspace_id = 'workspace-a' AND worker_id = '00000000-0000-7000-8000-000000000001'; WHERE workspace_id = 'workspace-a' AND worker_id = '00000000-0000-7000-8000-000000000001';
DELETE FROM typed_tickets DELETE FROM typed_tickets
WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a'; 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(()) Ok(())
@@ -8356,6 +8477,18 @@ WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a';
|row| row.get(0), |row| row.get(0),
)?; )?;
assert_eq!(retained, 1); 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(()) Ok(())
}) })
.unwrap(); .unwrap();
@@ -8509,6 +8642,12 @@ INSERT INTO ticket_worker_assignments (
'workspace-b', 'ticket-b', 'assignment-runtime-mismatch', 'runtime-wrong', 'workspace-b', 'ticket-b', 'assignment-runtime-mismatch', 'runtime-wrong',
'00000000-0000-7000-8000-000000000002', 'tester', '2026-01-01' '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 ( INSERT INTO ticket_worker_assignments (
workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at
) VALUES ( ) VALUES (
@@ -8552,6 +8691,7 @@ INSERT INTO ticket_worker_assignment_events (
); );
assert!(error.contains("assignment-cross-worker"), "{error}"); assert!(error.contains("assignment-cross-worker"), "{error}");
assert!(error.contains("assignment-runtime-mismatch"), "{error}"); assert!(error.contains("assignment-runtime-mismatch"), "{error}");
assert!(error.contains("assignment-missing-parents"), "{error}");
assert!( assert!(
error.contains("ticket_worker_assignment_events.assignment_id"), error.contains("ticket_worker_assignment_events.assignment_id"),
"{error}" "{error}"
@@ -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; - 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; - 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 - 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. - restores `PRAGMA foreign_keys = ON` whether the transaction commits or rolls back.