merge: compose classification tags with Ticket case migration

This commit is contained in:
2026-10-11 02:57:45 +09:00
41 changed files with 6752 additions and 229 deletions
+37
View File
@@ -164,3 +164,40 @@ BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets
WHEN NEW.workflow_state NOT IN ('open', 'closed')
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade.
CREATE TABLE workspace_tags (
workspace_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
name TEXT NOT NULL,
normalized_name TEXT NOT NULL,
last_operation_key TEXT NOT NULL,
deleted INTEGER NOT NULL CHECK (deleted IN (0, 1)),
PRIMARY KEY (workspace_id, tag_id)
);
CREATE UNIQUE INDEX workspace_tags_active_name
ON workspace_tags(workspace_id, normalized_name) WHERE deleted = 0;
CREATE TABLE typed_ticket_tags (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, tag_id),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, tag_id) REFERENCES workspace_tags(workspace_id, tag_id) ON DELETE RESTRICT
);
CREATE INDEX typed_ticket_tags_workspace_tag
ON typed_ticket_tags(workspace_id, tag_id, ticket_id);
-- Immutable operation receipt and Workspace audit: exact input includes actor
-- attributes; before/result preserve stable identities and display-name snapshots.
-- No foreign keys to mutable rows: history survives deletion of current links.
CREATE TABLE workspace_tag_operations (
workspace_id TEXT NOT NULL,
operation_key TEXT NOT NULL,
kind TEXT NOT NULL,
target_id TEXT NOT NULL,
input_json TEXT NOT NULL,
source_context_json TEXT NOT NULL,
before_json TEXT NOT NULL,
result_json TEXT NOT NULL,
at TEXT NOT NULL,
PRIMARY KEY (workspace_id, operation_key)
);
+70 -3
View File
@@ -22,9 +22,17 @@ use thiserror::Error;
pub mod config;
mod sqlite_schema;
mod tags;
pub use tags::{
TagListRequest, TicketTagSetRequest, WorkspaceTag, WorkspaceTagAction, WorkspaceTagMutation,
};
pub mod tool;
pub use sqlite_schema::{migrate_sqlite_ticket_schema, verify_sqlite_ticket_schema};
pub use sqlite_schema::{
migrate_sqlite_ticket_schema, migrate_sqlite_ticket_schema_in_transaction,
verify_legacy_sqlite_ticket_schema, verify_pre_classification_ticket_schema,
verify_sqlite_ticket_schema,
};
const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024;
const MAX_ORCHESTRATION_PLAN_TEXT_BYTES: usize = 16 * 1024;
@@ -133,6 +141,7 @@ fn parse_sql_case_state(raw: &str, index: usize) -> rusqlite::Result<TicketState
fn read_ticket_summary_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TicketSummary> {
let workflow_state = row.get::<_, String>(7)?;
Ok(TicketSummary {
tags: Vec::new(),
id: row.get(0)?,
resource_key: None,
slug: row.get(1)?,
@@ -349,6 +358,7 @@ pub enum TicketEventKind {
Decision,
ImplementationReport,
StateChanged,
TagsChanged,
IntakeSummary,
StatusChanged,
Close,
@@ -364,6 +374,7 @@ impl TicketEventKind {
Self::Decision => "decision",
Self::ImplementationReport => "implementation_report",
Self::StateChanged => "state_changed",
Self::TagsChanged => "tags_changed",
Self::IntakeSummary => "intake_summary",
Self::StatusChanged => "status_changed",
Self::Close => "close",
@@ -379,6 +390,7 @@ impl TicketEventKind {
Self::Decision => "Decision".to_string(),
Self::ImplementationReport => "Implementation report".to_string(),
Self::StateChanged => "State changed".to_string(),
Self::TagsChanged => "Tags changed".to_string(),
Self::IntakeSummary => "Intake summary".to_string(),
Self::StatusChanged => "Status changed".to_string(),
Self::Close => "Closed".to_string(),
@@ -397,6 +409,7 @@ impl From<&str> for TicketEventKind {
"implementation_report" => Self::ImplementationReport,
"review" => Self::Comment,
"state_changed" => Self::StateChanged,
"tags_changed" => Self::TagsChanged,
"intake_summary" => Self::IntakeSummary,
"status_changed" => Self::StatusChanged,
"close" | "closed" => Self::Close,
@@ -1584,6 +1597,8 @@ pub struct TicketMeta {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TicketSummary {
#[serde(default)]
pub tags: Vec<WorkspaceTag>,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource_key: Option<String>,
@@ -1668,6 +1683,8 @@ pub struct TicketArtifactRef {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Ticket {
#[serde(default)]
pub tags: Vec<WorkspaceTag>,
pub meta: TicketMeta,
pub document: TicketDocument,
pub events: Vec<TicketEvent>,
@@ -1729,6 +1746,13 @@ impl TicketDoctorReport {
}
pub trait TicketBackend {
fn list_workspace_tags(&self, request: TagListRequest) -> Result<Vec<WorkspaceTag>>;
fn mutate_workspace_tag(&self, request: WorkspaceTagMutation) -> Result<WorkspaceTag>;
fn set_ticket_tags(
&self,
id: TicketIdOrSlug,
request: TicketTagSetRequest,
) -> Result<Vec<WorkspaceTag>>;
fn default_intake_ready_state_change_body(&self, from: &str) -> String;
fn list(&self, filter: TicketListQuery) -> Result<Vec<TicketSummary>>;
fn show(&self, id: TicketIdOrSlug) -> Result<Ticket>;
@@ -1788,6 +1812,16 @@ pub trait TicketBackend {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "operation", rename_all = "snake_case")]
pub enum TicketBackendOperation {
ListWorkspaceTags {
request: TagListRequest,
},
MutateWorkspaceTag {
request: WorkspaceTagMutation,
},
SetTicketTags {
id: TicketIdOrSlug,
request: TicketTagSetRequest,
},
DefaultIntakeReadyStateChangeBody {
from: String,
},
@@ -1863,6 +1897,8 @@ pub enum TicketBackendOperation {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "result", content = "value", rename_all = "snake_case")]
pub enum TicketBackendOperationResult {
WorkspaceTags(Vec<WorkspaceTag>),
WorkspaceTag(WorkspaceTag),
Text(String),
Unit,
Tickets(Vec<TicketSummary>),
@@ -1886,6 +1922,15 @@ where
B: TicketBackend + ?Sized,
{
Ok(match operation {
TicketBackendOperation::ListWorkspaceTags { request } => {
TicketBackendOperationResult::WorkspaceTags(backend.list_workspace_tags(request)?)
}
TicketBackendOperation::MutateWorkspaceTag { request } => {
TicketBackendOperationResult::WorkspaceTag(backend.mutate_workspace_tag(request)?)
}
TicketBackendOperation::SetTicketTags { id, request } => {
TicketBackendOperationResult::WorkspaceTags(backend.set_ticket_tags(id, request)?)
}
TicketBackendOperation::DefaultIntakeReadyStateChangeBody { from } => {
TicketBackendOperationResult::Text(
backend.default_intake_ready_state_change_body(&from),
@@ -2165,6 +2210,7 @@ impl SqliteTicketBackend {
.map_err(sqlite_err)?;
for summary in &mut summaries {
summary.resource_key = Self::resource_key_for(conn, &self.workspace_id, &summary.id)?;
summary.tags = self.load_ticket_tags(conn, &summary.id)?;
}
let has_more = summaries.len() > query.limit;
summaries.truncate(query.limit);
@@ -2242,6 +2288,7 @@ impl SqliteTicketBackend {
updated_at,
) = row.map_err(sqlite_err)?;
summaries.push(TicketSummary {
tags: self.load_ticket_tags(conn, &id)?,
resource_key: Self::resource_key_for(conn, &self.workspace_id, &id)?,
id,
slug,
@@ -2730,6 +2777,7 @@ impl SqliteTicketBackend {
current_closure,
case_history,
legacy_workflow_snapshot,
tags: self.load_ticket_tags(conn, ticket_id)?,
meta,
document: TicketDocument {
body: MarkdownText::new(body),
@@ -2899,7 +2947,9 @@ impl SqliteTicketBackend {
}
meta.labels =
self.load_ordered_values(conn, "typed_ticket_labels", "label", &meta.id)?;
summaries.push(ticket_summary_from_meta(meta));
let mut summary = ticket_summary_from_meta(meta);
summary.tags = self.load_ticket_tags(conn, &summary.id)?;
summaries.push(summary);
}
Ok(summaries)
}
@@ -2971,6 +3021,19 @@ fn finish_sqlite_transaction<R>(conn: &Connection, result: Result<R>) -> Result<
}
impl TicketBackend for SqliteTicketBackend {
fn list_workspace_tags(&self, request: TagListRequest) -> Result<Vec<WorkspaceTag>> {
self.list_tags(request)
}
fn mutate_workspace_tag(&self, request: WorkspaceTagMutation) -> Result<WorkspaceTag> {
self.mutate_tag(request)
}
fn set_ticket_tags(
&self,
id: TicketIdOrSlug,
request: TicketTagSetRequest,
) -> Result<Vec<WorkspaceTag>> {
self.set_tags(id, request)
}
fn default_intake_ready_state_change_body(&self, from: &str) -> String {
if is_japanese_record_language(self.record_language()) {
format!("Ticket のrouting readinessをreadyにしました。case stateは{from}のままです。\n")
@@ -3053,6 +3116,7 @@ impl TicketBackend for SqliteTicketBackend {
current_closure: None,
case_history: Vec::new(),
legacy_workflow_snapshot: None,
tags: Vec::new(),
meta,
document: TicketDocument {
body,
@@ -3219,7 +3283,8 @@ impl TicketBackend for SqliteTicketBackend {
let states = self.state_index(conn)?;
let relations = self.all_relations(conn)?;
let blockers = transitive_dependency_blockers(&ticket_id, &states, &relations)?;
let summary = ticket_summary_from_meta(ticket.meta);
let mut summary = ticket_summary_from_meta(ticket.meta);
summary.tags = ticket.tags;
let mut projection = project_ticket_workspace_item(&summary, &blockers, None);
projection.queue_guard = ticket_queue_guard(&summary, &blockers, None);
let queue_tickets = vec![ticket_id];
@@ -3668,6 +3733,7 @@ impl TicketBackend for SqliteTicketBackend {
fn ticket_summary_from_meta(meta: TicketMeta) -> TicketSummary {
TicketSummary {
tags: Vec::new(),
id: meta.id,
resource_key: meta.resource_key,
slug: meta.slug,
@@ -4464,6 +4530,7 @@ mod tests {
fn summary_with_state(state: TicketState) -> TicketSummary {
TicketSummary {
tags: Vec::new(),
id: "000TEST".to_string(),
resource_key: Some("T-1".to_string()),
slug: "000TEST".to_string(),
+532 -150
View File
@@ -7,23 +7,9 @@ use crate::{Result, TicketError, sqlite_err};
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 9;
const PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6;
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 10;
const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline";
#[derive(Clone, Copy)]
struct Migration {
version: i64,
name: &'static str,
apply: fn(&Connection) -> Result<()>,
}
const MIGRATIONS: &[Migration] = &[Migration {
version: LATEST_SQLITE_TICKET_SCHEMA_VERSION,
name: BASELINE_MIGRATION_NAME,
apply: create_latest_ticket_schema,
}];
#[derive(Clone, Copy)]
struct ExpectedColumn {
name: &'static str,
@@ -41,6 +27,9 @@ struct ExpectedForeignKey {
}
const OWNED_TABLES: &[&str] = &[
"workspace_tags",
"typed_ticket_tags",
"workspace_tag_operations",
"typed_tickets",
"typed_ticket_targets",
"typed_ticket_labels",
@@ -99,6 +88,57 @@ const TICKET_COLUMNS: &[ExpectedColumn] = &[
column("resolution", "TEXT", false, 0),
];
const TAG_COLUMNS: &[ExpectedColumn] = &[
column("workspace_id", "TEXT", true, 1),
column("tag_id", "TEXT", true, 2),
column("name", "TEXT", true, 0),
column("normalized_name", "TEXT", true, 0),
column("last_operation_key", "TEXT", true, 0),
column("deleted", "INTEGER", true, 0),
];
const TICKET_TAG_COLUMNS: &[ExpectedColumn] = &[
column("workspace_id", "TEXT", true, 1),
column("ticket_id", "TEXT", true, 2),
column("tag_id", "TEXT", true, 3),
];
const TAG_OPERATION_COLUMNS: &[ExpectedColumn] = &[
column("workspace_id", "TEXT", true, 1),
column("operation_key", "TEXT", true, 2),
column("kind", "TEXT", true, 0),
column("target_id", "TEXT", true, 0),
column("input_json", "TEXT", true, 0),
column("source_context_json", "TEXT", true, 0),
column("before_json", "TEXT", true, 0),
column("result_json", "TEXT", true, 0),
column("at", "TEXT", true, 0),
];
const TICKET_TAG_FOREIGN_KEYS: &[ExpectedForeignKey] = &[
ExpectedForeignKey {
from: "workspace_id",
target_table: "typed_tickets",
to: "workspace_id",
on_delete: "CASCADE",
},
ExpectedForeignKey {
from: "ticket_id",
target_table: "typed_tickets",
to: "ticket_id",
on_delete: "CASCADE",
},
ExpectedForeignKey {
from: "workspace_id",
target_table: "workspace_tags",
to: "workspace_id",
on_delete: "RESTRICT",
},
ExpectedForeignKey {
from: "tag_id",
target_table: "workspace_tags",
to: "tag_id",
on_delete: "RESTRICT",
},
];
const TARGET_COLUMNS: &[ExpectedColumn] = &[
column("workspace_id", "TEXT", true, 1),
column("ticket_id", "TEXT", true, 2),
@@ -257,71 +297,7 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
.execute_batch("BEGIN IMMEDIATE")
.map_err(sqlite_err)?;
let result = (|| {
connection
.execute_batch(
"CREATE TABLE IF NOT EXISTS ticket_schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
);",
)
.map_err(sqlite_err)?;
verify_table(connection, MIGRATION_TABLE, MIGRATION_COLUMNS, &[], false)?;
let applied = load_applied_migrations(connection)?;
if applied.is_empty() {
let migration = MIGRATIONS
.first()
.ok_or_else(|| TicketError::Sqlite("Ticket migration catalog is empty".into()))?;
(migration.apply)(connection)?;
connection
.execute(
"INSERT INTO ticket_schema_migrations (version, name, applied_at)
VALUES (?1, ?2, ?3)",
params![
migration.version,
migration.name,
chrono::Utc::now().to_rfc3339()
],
)
.map_err(sqlite_err)?;
} else if applied
== BTreeMap::from([(
PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION,
BASELINE_MIGRATION_NAME.to_string(),
)])
{
migrate_v6_to_v7(connection)?;
migrate_v7_to_v9(connection)?;
connection
.execute(
"DELETE FROM ticket_schema_migrations WHERE version = ?1",
params![PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION],
)
.map_err(sqlite_err)?;
connection
.execute(
"INSERT INTO ticket_schema_migrations (version, name, applied_at)
VALUES (?1, ?2, ?3)",
params![
LATEST_SQLITE_TICKET_SCHEMA_VERSION,
BASELINE_MIGRATION_NAME,
chrono::Utc::now().to_rfc3339()
],
)
.map_err(sqlite_err)?;
} else if applied == BTreeMap::from([(7, BASELINE_MIGRATION_NAME.to_string())]) {
migrate_v7_to_v9(connection)?;
connection.execute("UPDATE ticket_schema_migrations SET version = 9, applied_at = ?1 WHERE version = 7", params![chrono::Utc::now().to_rfc3339()]).map_err(sqlite_err)?;
} else {
// Version 8 belongs to T-740; compose its tag migration explicitly at integration.
validate_applied_migrations(&applied)?;
}
verify_sqlite_ticket_schema(connection)
})();
let result = migrate_sqlite_ticket_schema_in_transaction(connection);
match result {
Ok(()) => connection.execute_batch("COMMIT").map_err(sqlite_err),
Err(error) => {
@@ -331,8 +307,154 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
}
}
/// Composes retained component 6/7, tags-only 8, or cases-only 9 into component 10.
/// The host owns the surrounding transaction (for example Workspace 90→91).
/// A savepoint rolls back this component on failure, without committing host work.
/// Current component 10 is verified only; immutable judgments are never reapplied.
pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> Result<()> {
if connection.is_autocommit() {
return Err(TicketError::Sqlite(
"Ticket component migration requires a host transaction".into(),
));
}
connection
.execute_batch("SAVEPOINT ticket_component_composition")
.map_err(sqlite_err)?;
let result = (|| {
connection
.execute_batch(
"CREATE TABLE IF NOT EXISTS ticket_schema_migrations (
version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL
);",
)
.map_err(sqlite_err)?;
verify_table(connection, MIGRATION_TABLE, MIGRATION_COLUMNS, &[], false)?;
let applied = load_applied_migrations(connection)?;
if applied.is_empty() {
create_latest_ticket_schema(connection)?;
connection
.execute(
"INSERT INTO ticket_schema_migrations VALUES (10, ?1, ?2)",
params![BASELINE_MIGRATION_NAME, chrono::Utc::now().to_rfc3339()],
)
.map_err(sqlite_err)?;
} else {
let version = match applied.iter().next() {
Some((&version, name))
if applied.len() == 1
&& name == BASELINE_MIGRATION_NAME
&& matches!(version, 6..=10) =>
{
version
}
_ => {
validate_applied_migrations(&applied)?;
unreachable!()
}
};
if version == 6 {
migrate_v6_to_v7(connection)?;
connection
.execute(
"UPDATE ticket_schema_migrations SET version = 7 WHERE version = 6",
[],
)
.map_err(sqlite_err)?;
}
let shape = TicketSchemaShape::from_version(if version == 6 { 7 } else { version });
verify_ticket_schema_shape(connection, shape)?;
match shape {
TicketSchemaShape::Legacy7 => {
add_tag_schema(connection)?;
migrate_legacy_cases(connection)?;
}
TicketSchemaShape::Tags8 => migrate_legacy_cases(connection)?,
TicketSchemaShape::Cases9 => add_tag_schema(connection)?,
TicketSchemaShape::Composed10 => {}
}
if version != 10 {
connection
.execute(
"UPDATE ticket_schema_migrations SET version = 10, applied_at = ?1",
params![chrono::Utc::now().to_rfc3339()],
)
.map_err(sqlite_err)?;
}
}
verify_sqlite_ticket_schema(connection)
})();
match result {
Ok(()) => connection
.execute_batch("RELEASE ticket_component_composition")
.map_err(sqlite_err),
Err(error) => {
let _ = connection.execute_batch(
"ROLLBACK TO ticket_component_composition; RELEASE ticket_component_composition",
);
Err(error)
}
}
}
#[derive(Clone, Copy)]
enum TicketSchemaShape {
Legacy7,
Tags8,
Cases9,
Composed10,
}
impl TicketSchemaShape {
fn from_version(version: i64) -> Self {
match version {
7 => Self::Legacy7,
8 => Self::Tags8,
9 => Self::Cases9,
10 => Self::Composed10,
_ => unreachable!(),
}
}
fn version(self) -> i64 {
match self {
Self::Legacy7 => 7,
Self::Tags8 => 8,
Self::Cases9 => 9,
Self::Composed10 => 10,
}
}
fn tags(self) -> bool {
matches!(self, Self::Tags8 | Self::Composed10)
}
fn cases(self) -> bool {
matches!(self, Self::Cases9 | Self::Composed10)
}
}
/// Verifies the current Ticket-owned SQLite schema without executing DDL.
pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
verify_ticket_schema_shape(connection, TicketSchemaShape::Composed10)
}
/// Retained Workspace schema-66 migration produces the pre-classification v7
/// Ticket shape. Validate that intermediate contract before the normal startup
/// path upgrades it; never require future tag tables inside that old transaction.
pub fn verify_pre_classification_ticket_schema(connection: &Connection) -> Result<()> {
verify_ticket_schema_shape(connection, TicketSchemaShape::Legacy7)
}
/// Verifies retained six-state component 7 or tags-only component 8, without
/// treating historical workflow values as the current two-state case authority.
pub fn verify_legacy_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
let applied = load_applied_migrations(connection)?;
let version = applied.keys().next().copied().unwrap_or_default();
if !matches!(version, 7 | 8) {
return Err(TicketError::Sqlite(
"legacy Ticket schema requires component 7 or 8".into(),
));
}
verify_ticket_schema_shape(connection, TicketSchemaShape::from_version(version))
}
fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape) -> Result<()> {
let mut diagnostics = Vec::new();
collect_table_diagnostics(
@@ -345,23 +467,35 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
match load_applied_migrations(connection) {
Ok(applied) => {
if let Err(error) = validate_applied_migrations(&applied) {
let history = if applied
== BTreeMap::from([(shape.version(), BASELINE_MIGRATION_NAME.to_owned())])
{
Ok(())
} else {
Err(TicketError::Sqlite(format!(
"Ticket schema migration history must contain only the canonical version {} baseline marker",
shape.version()
)))
};
if let Err(error) = history {
push_diagnostic(&mut diagnostics, error.to_string());
} else if applied.len() != MIGRATIONS.len() {
push_diagnostic(
&mut diagnostics,
format!(
"Ticket schema is not current: found {} migration(s), expected {}",
applied.len(),
MIGRATIONS.len()
),
);
}
}
Err(error) => push_diagnostic(&mut diagnostics, error.to_string()),
}
for (table, columns, foreign_keys) in [
("workspace_tags", TAG_COLUMNS, TICKET_FOREIGN_KEYS),
(
"typed_ticket_tags",
TICKET_TAG_COLUMNS,
TICKET_TAG_FOREIGN_KEYS,
),
(
"workspace_tag_operations",
TAG_OPERATION_COLUMNS,
TICKET_FOREIGN_KEYS,
),
("typed_tickets", TICKET_COLUMNS, TICKET_FOREIGN_KEYS),
("typed_ticket_targets", TARGET_COLUMNS, CHILD_FOREIGN_KEYS),
("typed_ticket_labels", LABEL_COLUMNS, CHILD_FOREIGN_KEYS),
@@ -402,9 +536,47 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
CHILD_FOREIGN_KEYS,
),
] {
if !shape.tags()
&& matches!(
table,
"workspace_tags" | "typed_ticket_tags" | "workspace_tag_operations"
)
{
continue;
}
collect_table_diagnostics(connection, table, columns, foreign_keys, &mut diagnostics);
}
if shape.tags() {
collect_index_diagnostics(
connection,
"workspace_tags",
Some("workspace_tags_active_name"),
true,
&["workspace_id", "normalized_name"],
&mut diagnostics,
);
collect_index_diagnostics(
connection,
"typed_ticket_tags",
Some("typed_ticket_tags_workspace_tag"),
false,
&["workspace_id", "tag_id", "ticket_id"],
&mut diagnostics,
);
let tag_name_index_sql: Option<String> = connection.query_row("SELECT sql FROM sqlite_master WHERE type='index' AND name='workspace_tags_active_name'", [], |row| row.get(0)).optional().map_err(sqlite_err)?;
if !tag_name_index_sql.is_some_and(|sql| {
sql.split_whitespace()
.collect::<String>()
.to_lowercase()
.ends_with("wheredeleted=0")
}) {
push_diagnostic(
&mut diagnostics,
"workspace_tags_active_name must be partial WHERE deleted = 0".into(),
);
}
}
collect_index_diagnostics(
connection,
"typed_ticket_targets",
@@ -456,64 +628,144 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
collect_absent_table_diagnostic(connection, legacy_table, &mut diagnostics);
}
for (table, columns) in [
(
if shape.cases() {
for (table, columns) in [
(
"typed_ticket_case_metadata",
vec![
column("workspace_id", "TEXT", true, 1),
column("ticket_id", "TEXT", true, 2),
column("closure_operation_key", "TEXT", false, 0),
],
),
(
"typed_ticket_case_history",
vec![
column("workspace_id", "TEXT", true, 1),
column("ticket_id", "TEXT", true, 2),
column("ordinal", "INTEGER", true, 0),
column("operation_key", "TEXT", true, 3),
column("request_fingerprint", "TEXT", true, 0),
column("judgment_json", "TEXT", true, 0),
],
),
(
"typed_ticket_legacy_workflow_snapshot",
vec![
column("workspace_id", "TEXT", true, 1),
column("ticket_id", "TEXT", true, 2),
column("snapshot_json", "TEXT", true, 0),
],
),
] {
if let Err(error) = verify_table(connection, table, &columns, CHILD_FOREIGN_KEYS, false)
{
push_diagnostic(&mut diagnostics, error.to_string());
}
}
collect_index_diagnostics(
connection,
"typed_ticket_case_history",
None,
true,
&["workspace_id", "ticket_id", "ordinal"],
&mut diagnostics,
);
for name in [
"typed_ticket_case_history_no_update",
"typed_ticket_case_history_no_delete",
"typed_ticket_legacy_workflow_no_update",
"typed_ticket_legacy_workflow_no_delete",
"typed_ticket_case_state_insert",
"typed_ticket_case_state_update",
] {
let sql: Option<String> = connection
.query_row(
"SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?1",
params![name],
|row| row.get(0),
)
.optional()
.map_err(sqlite_err)?;
let canonical = include_str!("case_schema.sql")
.split("CREATE TRIGGER ")
.skip(1)
.find(|ddl| ddl.starts_with(name))
.expect("canonical case trigger")
.split_once("END;")
.expect("trigger terminator")
.0;
let canonical = format!("CREATE TRIGGER {canonical}END");
let normalize = |sql: &str| {
sql.trim_end_matches(';')
.split_whitespace()
.collect::<String>()
.to_lowercase()
};
match sql {
None => push_diagnostic(
&mut diagnostics,
format!("missing Ticket case trigger {name}"),
),
Some(sql) if normalize(&sql) != normalize(&canonical) => push_diagnostic(
&mut diagnostics,
format!("invalid Ticket case trigger {name}"),
),
Some(_) => {}
}
}
} else {
for table in [
"typed_ticket_case_metadata",
vec![
column("workspace_id", "TEXT", true, 1),
column("ticket_id", "TEXT", true, 2),
column("closure_operation_key", "TEXT", false, 0),
],
),
(
"typed_ticket_case_history",
vec![
column("workspace_id", "TEXT", true, 1),
column("ticket_id", "TEXT", true, 2),
column("ordinal", "INTEGER", true, 0),
column("operation_key", "TEXT", true, 3),
column("request_fingerprint", "TEXT", true, 0),
column("judgment_json", "TEXT", true, 0),
],
),
(
"typed_ticket_legacy_workflow_snapshot",
vec![
column("workspace_id", "TEXT", true, 1),
column("ticket_id", "TEXT", true, 2),
column("snapshot_json", "TEXT", true, 0),
],
),
] {
if let Err(error) = verify_table(connection, table, &columns, CHILD_FOREIGN_KEYS, false) {
push_diagnostic(&mut diagnostics, error.to_string());
] {
collect_absent_table_diagnostic(connection, table, &mut diagnostics);
}
}
for name in [
"typed_ticket_case_history_no_update",
"typed_ticket_case_history_no_delete",
"typed_ticket_legacy_workflow_no_update",
"typed_ticket_legacy_workflow_no_delete",
"typed_ticket_case_state_insert",
"typed_ticket_case_state_update",
] {
let exists = connection
.query_row(
"SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = ?1",
params![name],
|_| Ok(()),
)
.optional()
.map_err(sqlite_err)?
.is_some();
if !exists {
push_diagnostic(
&mut diagnostics,
format!("missing Ticket case trigger {name}"),
);
if !shape.tags() {
for table in [
"workspace_tags",
"typed_ticket_tags",
"workspace_tag_operations",
] {
collect_absent_table_diagnostic(connection, table, &mut diagnostics);
}
}
let invalid_states: i64 = connection.query_row(
if shape.cases() {
"SELECT COUNT(*) FROM typed_tickets WHERE workflow_state NOT IN ('open','closed')"
} else {
"SELECT COUNT(*) FROM typed_tickets WHERE workflow_state NOT IN ('planning','ready','queued','inprogress','done','closed')"
}, [], |row| row.get(0)).map_err(sqlite_err)?;
if invalid_states != 0 {
push_diagnostic(
&mut diagnostics,
format!(
"component {} has {invalid_states} unsupported Ticket states",
shape.version()
),
);
}
for table in OWNED_TABLES {
if !shape.tags()
&& matches!(
*table,
"workspace_tags" | "typed_ticket_tags" | "workspace_tag_operations"
)
{
continue;
}
if !shape.cases()
&& matches!(
*table,
"typed_ticket_case_metadata"
| "typed_ticket_case_history"
| "typed_ticket_legacy_workflow_snapshot"
)
{
continue;
}
collect_foreign_key_check_diagnostics(connection, table, &mut diagnostics);
}
@@ -539,9 +791,8 @@ fn create_latest_ticket_schema(connection: &Connection) -> Result<()> {
.map_err(sqlite_err)
}
/// Independent 7→9 case migration. Does not rewrite historical events or
/// create/resume worker identities. Version 8 tag composition is deliberately separate.
fn migrate_v7_to_v9(connection: &Connection) -> Result<()> {
/// Retained 7/8 case migration: leave tags and historical receipts untouched.
fn migrate_legacy_cases(connection: &Connection) -> Result<()> {
let snapshots = {
let mut stmt = connection.prepare("SELECT workspace_id, ticket_id, workflow_state, workflow_state_explicit, status, queued_by, queued_at, resolution FROM typed_tickets").map_err(sqlite_err)?;
stmt.query_map([], |row| {
@@ -589,7 +840,7 @@ fn migrate_v7_to_v9(connection: &Connection) -> Result<()> {
for (workspace_id, ticket_id, snapshot) in snapshots {
connection.execute("INSERT INTO typed_ticket_legacy_workflow_snapshot (workspace_id, ticket_id, snapshot_json) VALUES (?1, ?2, ?3)", params![workspace_id, ticket_id, serde_json::to_string(&snapshot).map_err(sqlite_err)?]).map_err(sqlite_err)?;
if matches!(snapshot.workflow_state.as_str(), "done" | "closed") {
let key = format!("migration-v9:{ticket_id}");
let key = format!("migration-v10:{ticket_id}");
let judgment = crate::TicketCaseHistoryEntry {
operation_key: key.clone(),
previous_closure_operation_key: None,
@@ -608,15 +859,22 @@ fn migrate_v7_to_v9(connection: &Connection) -> Result<()> {
)
}),
references: Vec::new(),
author: "ticket-schema-migration-v9".into(),
author: "ticket-schema-migration-v10".into(),
at: chrono::Utc::now().to_rfc3339(),
};
connection.execute("INSERT INTO typed_ticket_case_history (workspace_id, ticket_id, ordinal, operation_key, request_fingerprint, judgment_json) VALUES (?1, ?2, 0, ?3, 'migration-v9', ?4)", params![workspace_id, ticket_id, key, serde_json::to_string(&judgment).map_err(sqlite_err)?]).map_err(sqlite_err)?;
connection.execute("INSERT INTO typed_ticket_case_history (workspace_id, ticket_id, ordinal, operation_key, request_fingerprint, judgment_json) VALUES (?1, ?2, 0, ?3, 'migration-v10', ?4)", params![workspace_id, ticket_id, key, serde_json::to_string(&judgment).map_err(sqlite_err)?]).map_err(sqlite_err)?;
connection.execute("INSERT INTO typed_ticket_case_metadata (workspace_id, ticket_id, closure_operation_key) VALUES (?1, ?2, ?3)", params![workspace_id, ticket_id, key]).map_err(sqlite_err)?;
}
}
Ok(())
}
fn add_tag_schema(connection: &Connection) -> Result<()> {
// Use the exact canonical DDL rather than duplicating migration definitions.
let (_, tag_schema) = include_str!("latest_schema.sql")
.split_once("-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade.")
.expect("canonical tag schema marker");
connection.execute_batch(tag_schema).map_err(sqlite_err)
}
fn migrate_v6_to_v7(connection: &Connection) -> Result<()> {
connection
@@ -780,7 +1038,7 @@ fn load_applied_migrations(connection: &Connection) -> Result<BTreeMap<i64, Stri
fn validate_applied_migrations(applied: &BTreeMap<i64, String>) -> Result<()> {
let expected = BTreeMap::from([(
LATEST_SQLITE_TICKET_SCHEMA_VERSION,
MIGRATIONS[0].name.to_string(),
BASELINE_MIGRATION_NAME.to_string(),
)]);
if applied == &expected {
Ok(())
@@ -1283,10 +1541,134 @@ mod tests {
assert!(!migrated_attributes.contains_key("ref_selector"));
assert_eq!(
load_applied_migrations(&connection).unwrap(),
BTreeMap::from([(9, BASELINE_MIGRATION_NAME.to_owned())])
BTreeMap::from([(10, BASELINE_MIGRATION_NAME.to_owned())])
);
}
#[test]
fn migrates_v7_to_identical_fresh_schema_without_touching_legacy_labels() {
let upgraded = Connection::open_in_memory().unwrap();
let legacy_sql = include_str!("../tests/fixtures/schema7.sql");
upgraded.execute_batch(legacy_sql).unwrap();
upgraded.execute_batch(
"CREATE TABLE ticket_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL);
INSERT INTO ticket_schema_migrations VALUES (7, 'ticket schema baseline', 'old');
INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
VALUES ('ws','ticket','ticket','Title','open','task','P2','body','ready',1);
INSERT INTO typed_ticket_labels VALUES ('ws','ticket',0,' Legacy Label ');"
).unwrap();
migrate_sqlite_ticket_schema(&upgraded).unwrap();
verify_sqlite_ticket_schema(&upgraded).unwrap();
let fresh = Connection::open_in_memory().unwrap();
migrate_sqlite_ticket_schema(&fresh).unwrap();
let schema = |conn: &Connection| {
let mut statement = conn.prepare("SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name").unwrap();
statement
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?
.map(|sql| sql.split_whitespace().collect::<String>()),
))
})
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap()
};
assert_eq!(schema(&upgraded), schema(&fresh));
let label: String = upgraded
.query_row("SELECT label FROM typed_ticket_labels", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(label, " Legacy Label ");
let tags: i64 = upgraded
.query_row("SELECT COUNT(*) FROM workspace_tags", [], |row| row.get(0))
.unwrap();
assert_eq!(tags, 0); // Legacy labels are never promoted to catalog tags.
migrate_sqlite_ticket_schema(&upgraded).unwrap();
assert_eq!(schema(&upgraded), schema(&fresh));
}
#[test]
fn tag_schema_verification_detects_drift_without_repair() {
for (drift, expected) in [
("DROP TABLE typed_ticket_tags", "typed_ticket_tags"),
(
"ALTER TABLE workspace_tags RENAME COLUMN last_operation_key TO wrong_observed_key",
"last_operation_key",
),
(
"ALTER TABLE workspace_tag_operations DROP COLUMN result_json",
"result_json",
),
(
"DROP INDEX typed_ticket_tags_workspace_tag",
"typed_ticket_tags_workspace_tag",
),
(
"DROP INDEX workspace_tags_active_name; CREATE UNIQUE INDEX workspace_tags_active_name ON workspace_tags(workspace_id, normalized_name)",
"partial WHERE deleted = 0",
),
(
"DROP TABLE typed_ticket_tags; CREATE TABLE typed_ticket_tags (workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,tag_id TEXT NOT NULL, PRIMARY KEY(workspace_id,ticket_id,tag_id)); CREATE INDEX typed_ticket_tags_workspace_tag ON typed_ticket_tags(workspace_id,tag_id,ticket_id)",
"missing foreign key",
),
] {
let connection = Connection::open_in_memory().unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
connection.execute_batch(drift).unwrap();
let error = verify_sqlite_ticket_schema(&connection).unwrap_err();
assert!(
error.to_string().contains(expected),
"after {drift}: {error}"
);
assert!(migrate_sqlite_ticket_schema(&connection).is_err());
}
}
#[test]
fn tag_schema_rejects_cross_workspace_links_and_preserves_tombstones() {
let connection = Connection::open_in_memory().unwrap();
connection
.pragma_update(None, "foreign_keys", "ON")
.unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
connection.execute_batch(
"INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
VALUES ('one','ticket','ticket','Title','open','task','P2','body','open',1);
INSERT INTO workspace_tags VALUES ('two','tag','Name','name','create',0);"
).unwrap();
assert!(
connection
.execute(
"INSERT INTO typed_ticket_tags VALUES ('one','ticket','tag')",
[]
)
.is_err()
);
connection
.execute(
"INSERT INTO workspace_tags VALUES ('one','tag','Name','name','create',0)",
[],
)
.unwrap();
connection
.execute(
"INSERT INTO typed_ticket_tags VALUES ('one','ticket','tag')",
[],
)
.unwrap();
assert!(
connection
.execute("DELETE FROM workspace_tags WHERE workspace_id='one'", [])
.is_err()
);
verify_sqlite_ticket_schema(&connection).unwrap();
}
#[test]
fn rejects_unknown_future_migration_history_without_changing_schema() {
let connection = Connection::open_in_memory().unwrap();
@@ -1301,7 +1683,7 @@ mod tests {
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains(
"migration history must contain only the canonical version 9 baseline marker"
"migration history must contain only the canonical version 10 baseline marker"
));
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2);
}
@@ -1323,7 +1705,7 @@ mod tests {
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains(
"migration history must contain only the canonical version 9 baseline marker"
"migration history must contain only the canonical version 10 baseline marker"
));
assert!(!table_exists(&connection, "typed_tickets").unwrap());
}
+323
View File
@@ -0,0 +1,323 @@
//! Workspace tag identity, display-name policy, and transactional SQLite authority.
//!
//! Display names use Rust `str::trim` (Unicode whitespace), then Unicode
//! `str::to_lowercase` for active-name uniqueness. No NFC/NFKC normalization or
//! full case folding is performed: canonically equivalent spellings can differ.
//! Any control character in the input is rejected, including trimmed edges.
//! The trimmed display name must be nonempty and at most 128 UTF-8 bytes.
//! IDs are immutable; deleted names may be recreated under a new ID.
use super::*;
#[cfg(test)]
#[path = "tags_tests.rs"]
mod tests;
/// Workspace-scoped immutable identity with a mutable display name.
/// Names are Unicode-whitespace trimmed, limited to 128 UTF-8 bytes, and
/// deduplicated among active tags using Rust Unicode lowercase (not full case
/// folding or NFC/NFKC). Empty names and any input control characters are invalid.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WorkspaceTag {
pub workspace_id: String,
pub id: String,
pub name: String,
pub last_operation_key: String,
pub deleted: bool,
}
/// Active tags in ascending ID order, strictly after `after`; default/max 200.
/// Larger limits are clamped to 200; zero returns an empty page.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TagListRequest {
pub after: Option<String>,
#[schemars(range(min = 0, max = 200))]
pub limit: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceTagMutation {
pub operation_key: String,
pub action: WorkspaceTagAction,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum WorkspaceTagAction {
Create {
name: String,
},
Rename {
tag_id: String,
expected_last_operation_key: String,
name: String,
},
Delete {
tag_id: String,
expected_last_operation_key: String,
},
}
/// Compare entire active ID set, then replace it atomically. IDs must be unique;
/// input order is immaterial to CAS, but remains significant for exact replay.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketTagSetRequest {
pub expected_tag_ids: Vec<String>,
pub tag_ids: Vec<String>,
pub operation_key: String,
}
fn normalize_name(input: &str) -> Result<(String, String)> {
let name = input.trim();
if name.is_empty() || name.len() > 128 || input.chars().any(char::is_control) {
return Err(TicketError::Conflict("tag name must be nonempty, contain no control characters, and be at most 128 UTF-8 bytes after trimming".into()));
}
Ok((name.to_owned(), name.to_lowercase()))
}
fn sorted_ids(ids: &[String]) -> Result<Vec<String>> {
if ids.len() > 100 || ids.iter().any(|id| id.len() > 128) {
return Err(TicketError::Conflict(
"tag sets require at most 100 IDs of at most 128 bytes".into(),
));
}
let mut sorted = ids.to_vec();
sorted.sort();
if sorted.iter().any(|id| id.trim().is_empty())
|| sorted.windows(2).any(|pair| pair[0] == pair[1])
{
return Err(TicketError::Conflict(
"tag IDs must be nonempty and unique".into(),
));
}
Ok(sorted)
}
fn read_tag(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkspaceTag> {
Ok(WorkspaceTag {
workspace_id: row.get(0)?,
id: row.get(1)?,
name: row.get(2)?,
last_operation_key: row.get(3)?,
deleted: row.get::<_, i64>(4)? != 0,
})
}
impl SqliteTicketBackend {
pub(crate) fn load_ticket_tags(
&self,
conn: &Connection,
ticket_id: &str,
) -> Result<Vec<WorkspaceTag>> {
let mut statement = conn.prepare("SELECT tag.workspace_id,tag.tag_id,tag.name,tag.last_operation_key,tag.deleted FROM workspace_tags AS tag JOIN typed_ticket_tags AS link ON link.workspace_id=tag.workspace_id AND link.tag_id=tag.tag_id WHERE link.workspace_id=?1 AND link.ticket_id=?2 AND tag.deleted=0 ORDER BY tag.tag_id").map_err(sqlite_err)?;
statement
.query_map(params![self.workspace_id, ticket_id], read_tag)
.map_err(sqlite_err)?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(sqlite_err)
}
fn load_tag(&self, conn: &Connection, id: &str) -> Result<WorkspaceTag> {
conn.query_row("SELECT workspace_id,tag_id,name,last_operation_key,deleted FROM workspace_tags WHERE workspace_id=?1 AND tag_id=?2", params![self.workspace_id,id], read_tag).optional().map_err(sqlite_err)?.ok_or_else(|| TicketError::NotFound(format!("workspace tag {id}")))
}
fn tag_input(&self, kind: &str, target: &str, request: &impl Serialize) -> Result<String> {
// Bind authenticated identity, not its mutable assignment/role context:
// a receipt is still the same request after the Worker is reassigned.
// The complete original context is retained separately in the audit row.
let identity: BTreeMap<_, _> = self
.event_attributes
.iter()
.filter(|(key, _)| {
matches!(
key.as_str(),
"source_account_id"
| "source_user_id"
| "source_runtime_id"
| "source_worker_id"
)
})
.collect();
serde_json::to_string(&(kind, target, request, identity)).map_err(sqlite_err)
}
fn replay_tag_operation<T: serde::de::DeserializeOwned>(
&self,
conn: &Connection,
key: &str,
input: &str,
) -> Result<Option<T>> {
validate_required_event_value("operation_key", key)?;
let receipt: Option<(String,String)> = conn.query_row("SELECT input_json,result_json FROM workspace_tag_operations WHERE workspace_id=?1 AND operation_key=?2",params![self.workspace_id,key],|row| Ok((row.get(0)?,row.get(1)?))).optional().map_err(sqlite_err)?;
match receipt {
Some((original, result)) if original == input => {
Ok(Some(serde_json::from_str(&result).map_err(sqlite_err)?))
}
Some(_) => Err(TicketError::OperationFingerprintMismatch {
operation_key: key.to_owned(),
}),
None => Ok(None),
}
}
fn record_tag_operation(
&self,
conn: &Connection,
key: &str,
kind: &str,
target: &str,
input: &str,
snapshots: (&impl Serialize, &impl Serialize),
) -> Result<()> {
conn.execute("INSERT INTO workspace_tag_operations (workspace_id,operation_key,kind,target_id,input_json,before_json,result_json,at,source_context_json) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)", params![self.workspace_id,key,kind,target,input,serde_json::to_string(snapshots.0).map_err(sqlite_err)?,serde_json::to_string(snapshots.1).map_err(sqlite_err)?,now_utc(),serde_json::to_string(&self.event_attributes).map_err(sqlite_err)?]).map_err(sqlite_err)?;
Ok(())
}
fn audit_ticket_tags(
&self,
conn: &Connection,
ticket_id: &str,
key: &str,
before: &[WorkspaceTag],
after: &[WorkspaceTag],
) -> Result<()> {
self.insert_event(
conn,
ticket_id,
&TicketEvent {
kind: TicketEventKind::TagsChanged,
author: Some(default_author()),
at: Some(now_utc()),
status: None,
from: None,
to: None,
reason: None,
state_field: None,
heading: Some("Tags changed".into()),
body: MarkdownText::new("Ticket tags changed"),
references: Vec::new(),
attributes: BTreeMap::from([
("operation_key".into(), key.to_owned()),
(
"tags_before".into(),
serde_json::to_string(before).map_err(sqlite_err)?,
),
(
"tags_after".into(),
serde_json::to_string(after).map_err(sqlite_err)?,
),
]),
},
)
}
pub(crate) fn list_tags(&self, request: TagListRequest) -> Result<Vec<WorkspaceTag>> {
self.with_read(|conn| {
let mut statement = conn.prepare("SELECT workspace_id,tag_id,name,last_operation_key,deleted FROM workspace_tags WHERE workspace_id=?1 AND deleted=0 AND (?2 IS NULL OR tag_id>?2) ORDER BY tag_id LIMIT ?3").map_err(sqlite_err)?;
statement.query_map(params![self.workspace_id,request.after,request.limit.unwrap_or(200).min(200) as i64],read_tag).map_err(sqlite_err)?.collect::<std::result::Result<Vec<_>,_>>().map_err(sqlite_err)
})
}
pub(crate) fn mutate_tag(&self, request: WorkspaceTagMutation) -> Result<WorkspaceTag> {
self.with_write(|conn| {
let input = self.tag_input("mutate_workspace_tag", "", &request)?;
if let Some(result) = self.replay_tag_operation(conn,&request.operation_key,&input)? { return Ok(result); }
let (before, result, kind) = match &request.action {
WorkspaceTagAction::Create { name } => {
let (name, normalized) = normalize_name(name)?;
self.require_unique_tag_name(conn,&normalized,None)?;
let millis = unix_epoch_millis_now().map_err(|error| TicketError::Conflict(format!("failed to read tag ID timestamp: {error}")))?;
let id = allocate_record_id(millis, |candidate| {
conn.query_row("SELECT 1 FROM workspace_tags WHERE workspace_id=?1 AND tag_id=?2",params![self.workspace_id,candidate],|_| Ok(())).optional().map(|row| row.is_some()).unwrap_or(true)
}).map_err(|error| TicketError::Conflict(error.to_string()))?;
let tag = WorkspaceTag { workspace_id: self.workspace_id.clone(), id, name, last_operation_key: request.operation_key.clone(), deleted: false };
conn.execute("INSERT INTO workspace_tags (workspace_id,tag_id,name,normalized_name,last_operation_key,deleted) VALUES (?1,?2,?3,?4,?5,0)",params![tag.workspace_id,tag.id,tag.name,normalized,tag.last_operation_key]).map_err(sqlite_err)?;
(None,tag,"create")
}
WorkspaceTagAction::Rename { tag_id,expected_last_operation_key,.. } | WorkspaceTagAction::Delete { tag_id,expected_last_operation_key } => {
let previous = self.load_tag(conn,tag_id)?;
if previous.deleted || &previous.last_operation_key != expected_last_operation_key {
return Err(TicketError::Conflict(format!("stale or deleted tag {tag_id}: expected operation {expected_last_operation_key:?}, actual {:?}",previous.last_operation_key)));
}
let mut tag = previous.clone();
tag.last_operation_key = request.operation_key.clone();
let kind = if let WorkspaceTagAction::Rename { name,.. } = &request.action {
let (name,normalized) = normalize_name(name)?;
self.require_unique_tag_name(conn,&normalized,Some(tag_id))?;
tag.name = name;
conn.execute("UPDATE workspace_tags SET name=?3,normalized_name=?4,last_operation_key=?5 WHERE workspace_id=?1 AND tag_id=?2",params![self.workspace_id,tag_id,tag.name,normalized,request.operation_key]).map_err(sqlite_err)?;
"rename"
} else {
// Capture active snapshots before tombstoning; links and audit are
// cleared/written under the same immediate lock as attachments.
let ticket_ids = {
let mut stmt = conn.prepare("SELECT ticket_id FROM typed_ticket_tags WHERE workspace_id=?1 AND tag_id=?2 ORDER BY ticket_id").map_err(sqlite_err)?;
stmt.query_map(params![self.workspace_id,tag_id],|row| row.get::<_,String>(0)).map_err(sqlite_err)?.collect::<std::result::Result<Vec<_>,_>>().map_err(sqlite_err)?
};
for ticket_id in ticket_ids {
let before = self.load_ticket_tags(conn,&ticket_id)?;
let after: Vec<_> = before.iter().filter(|tag| &tag.id != tag_id).cloned().collect();
conn.execute("DELETE FROM typed_ticket_tags WHERE workspace_id=?1 AND ticket_id=?2 AND tag_id=?3",params![self.workspace_id,ticket_id,tag_id]).map_err(sqlite_err)?;
self.audit_ticket_tags(conn,&ticket_id,&request.operation_key,&before,&after)?;
}
tag.deleted = true;
conn.execute("UPDATE workspace_tags SET deleted=1,last_operation_key=?3 WHERE workspace_id=?1 AND tag_id=?2",params![self.workspace_id,tag_id,request.operation_key]).map_err(sqlite_err)?;
"delete"
};
(Some(previous),tag,kind)
}
};
self.record_tag_operation(conn,&request.operation_key,kind,&result.id,&input,(&before,&result))?;
Ok(result)
})
}
fn require_unique_tag_name(
&self,
conn: &Connection,
normalized: &str,
except: Option<&str>,
) -> Result<()> {
let duplicate = conn.query_row("SELECT 1 FROM workspace_tags WHERE workspace_id=?1 AND normalized_name=?2 AND deleted=0 AND (?3 IS NULL OR tag_id<>?3)",params![self.workspace_id,normalized,except],|_| Ok(())).optional().map_err(sqlite_err)?.is_some();
if duplicate {
return Err(TicketError::Conflict(
"active workspace tag name already exists".into(),
));
}
Ok(())
}
pub(crate) fn set_tags(
&self,
id: TicketIdOrSlug,
request: TicketTagSetRequest,
) -> Result<Vec<WorkspaceTag>> {
self.with_write(|conn| {
// Preserve exact selector input, not just its resolved identity.
let input = self.tag_input("set_ticket_tags", "", &(&id,&request))?;
if let Some(result) = self.replay_tag_operation(conn,&request.operation_key,&input)? { return Ok(result); }
let ticket_id = self.resolve_ticket_id(conn,id)?;
self.require_active_source_assignment(conn,&ticket_id)?;
let before = self.load_ticket_tags(conn,&ticket_id)?;
let expected = sorted_ids(&request.expected_tag_ids)?;
let desired = sorted_ids(&request.tag_ids)?;
if before.iter().map(|tag| tag.id.clone()).collect::<Vec<_>>() != expected {
return Err(TicketError::Conflict("stale Ticket tag set".into()));
}
let after = desired.iter().map(|id| {
let tag = self.load_tag(conn,id)?;
if tag.deleted { return Err(TicketError::Conflict(format!("workspace tag {id} is deleted"))); }
Ok(tag)
}).collect::<Result<Vec<_>>>()?;
conn.execute("DELETE FROM typed_ticket_tags WHERE workspace_id=?1 AND ticket_id=?2",params![self.workspace_id,ticket_id]).map_err(sqlite_err)?;
for tag in &after {
conn.execute("INSERT INTO typed_ticket_tags (workspace_id,ticket_id,tag_id) VALUES (?1,?2,?3)",params![self.workspace_id,ticket_id,tag.id]).map_err(sqlite_err)?;
}
if before != after { self.audit_ticket_tags(conn,&ticket_id,&request.operation_key,&before,&after)?; }
self.record_tag_operation(conn,&request.operation_key,"set_ticket_tags",&ticket_id,&input,(&before,&after))?;
Ok(after)
})
}
}
+804
View File
@@ -0,0 +1,804 @@
use super::*;
use tempfile::tempdir;
fn create_tag(backend: &SqliteTicketBackend, key: &str, name: &str) -> WorkspaceTag {
backend
.mutate_workspace_tag(WorkspaceTagMutation {
operation_key: key.into(),
action: WorkspaceTagAction::Create { name: name.into() },
})
.unwrap()
}
fn rename_tag(
backend: &SqliteTicketBackend,
key: &str,
tag: &WorkspaceTag,
name: &str,
) -> WorkspaceTag {
backend
.mutate_workspace_tag(WorkspaceTagMutation {
operation_key: key.into(),
action: WorkspaceTagAction::Rename {
tag_id: tag.id.clone(),
expected_last_operation_key: tag.last_operation_key.clone(),
name: name.into(),
},
})
.unwrap()
}
fn delete_request(key: &str, tag: &WorkspaceTag) -> WorkspaceTagMutation {
WorkspaceTagMutation {
operation_key: key.into(),
action: WorkspaceTagAction::Delete {
tag_id: tag.id.clone(),
expected_last_operation_key: tag.last_operation_key.clone(),
},
}
}
fn set_request(
key: &str,
expected: &[WorkspaceTag],
desired: &[WorkspaceTag],
) -> TicketTagSetRequest {
TicketTagSetRequest {
expected_tag_ids: expected.iter().map(|t| t.id.clone()).collect(),
tag_ids: desired.iter().map(|t| t.id.clone()).collect(),
operation_key: key.into(),
}
}
fn audit_count(backend: &SqliteTicketBackend) -> i64 {
backend
.open_connection()
.unwrap()
.query_row(
"SELECT COUNT(*) FROM workspace_tag_operations WHERE workspace_id=?1",
params![backend.workspace_id],
|row| row.get(0),
)
.unwrap()
}
#[test]
fn tag_names_have_documented_unicode_lowercase_not_casefold_policy() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
let tag = create_tag(&backend, "a", "\u{2003}Ä BC\u{2003}");
assert_eq!(tag.name, "Ä BC");
for name in ["ä bc", " Ä BC "] {
assert!(
backend
.mutate_workspace_tag(WorkspaceTagMutation {
operation_key: "duplicate".into(),
action: WorkspaceTagAction::Create { name: name.into() }
})
.is_err()
);
}
for name in [
"",
" \u{2003}",
"bad\nname",
"\tname",
"a\0",
&"é".repeat(65),
] {
assert!(
backend
.mutate_workspace_tag(WorkspaceTagMutation {
operation_key: "invalid".into(),
action: WorkspaceTagAction::Create { name: name.into() }
})
.is_err()
);
}
create_tag(&backend, "boundary", &"é".repeat(64));
create_tag(&backend, "sharp", "ß");
create_tag(&backend, "ss", "SS"); // Lowercase, not full case folding.
create_tag(&backend, "composed", "é");
create_tag(&backend, "decomposed", "e\u{301}"); // No Unicode normalization.
assert_eq!(audit_count(&backend), 6);
}
#[test]
fn tag_rename_delete_cas_and_name_conflicts_leave_no_audit_or_changes() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
let a = create_tag(&backend, "a", "A");
let b = create_tag(&backend, "b", "B");
let renamed = rename_tag(&backend, "rename", &a, "New");
assert_eq!(a.id, renamed.id);
for action in [
WorkspaceTagAction::Rename {
tag_id: a.id.clone(),
expected_last_operation_key: "a".into(),
name: "Stale".into(),
},
WorkspaceTagAction::Delete {
tag_id: a.id.clone(),
expected_last_operation_key: "a".into(),
},
WorkspaceTagAction::Rename {
tag_id: renamed.id.clone(),
expected_last_operation_key: "rename".into(),
name: " b ".into(),
},
WorkspaceTagAction::Rename {
tag_id: renamed.id.clone(),
expected_last_operation_key: "rename".into(),
name: "\n".into(),
},
] {
assert!(
backend
.mutate_workspace_tag(WorkspaceTagMutation {
operation_key: "rejected".into(),
action
})
.is_err()
);
}
assert_eq!(audit_count(&backend), 3);
let active = backend
.list_workspace_tags(TagListRequest::default())
.unwrap();
assert!(active.contains(&renamed) && active.contains(&b));
}
#[test]
fn workspace_mutation_exact_replay_returns_stored_snapshot_before_cas() {
let dir = tempdir().unwrap();
let db = dir.path().join("tags.db");
let backend = SqliteTicketBackend::open(&db, "ws")
.unwrap()
.with_event_attributes(BTreeMap::from([(
"source_worker_id".into(),
"actor-a".into(),
)]));
let request = WorkspaceTagMutation {
operation_key: "create".into(),
action: WorkspaceTagAction::Create { name: " A ".into() },
};
let original = backend.mutate_workspace_tag(request.clone()).unwrap();
let renamed = rename_tag(&backend, "rename", &original, "B");
let deletion = delete_request("delete", &renamed);
let tombstone = backend.mutate_workspace_tag(deletion.clone()).unwrap();
assert!(tombstone.deleted);
assert_eq!(
backend.mutate_workspace_tag(request.clone()).unwrap(),
original
);
assert_eq!(
backend.mutate_workspace_tag(deletion.clone()).unwrap(),
tombstone
);
let restarted = SqliteTicketBackend::open_verified(&db, "ws")
.unwrap()
.with_event_attributes(backend.event_attributes.clone());
assert_eq!(
restarted.mutate_workspace_tag(request.clone()).unwrap(),
original
);
let other_actor = restarted.with_event_attributes(BTreeMap::from([(
"source_worker_id".into(),
"actor-b".into(),
)]));
assert!(matches!(
other_actor.mutate_workspace_tag(request),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert!(matches!(
backend.mutate_workspace_tag(WorkspaceTagMutation {
operation_key: "create".into(),
action: WorkspaceTagAction::Create { name: "A".into() }
}),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert_eq!(audit_count(&backend), 3);
assert!(
backend
.list_workspace_tags(TagListRequest::default())
.unwrap()
.is_empty()
);
}
#[test]
fn tag_deletion_detaches_every_ticket_keeps_audit_snapshots_and_recreate_new_id() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
let tag = create_tag(&backend, "create", "Old name");
let mut ids = Vec::new();
for i in 0..2 {
let id = backend
.create(NewTicket::new(format!("ticket {i}")))
.unwrap()
.id;
backend
.set_ticket_tags(
id.clone().into(),
set_request(&format!("attach-{i}"), &[], std::slice::from_ref(&tag)),
)
.unwrap();
ids.push(id);
}
let renamed = rename_tag(&backend, "rename", &tag, "New name");
backend
.mutate_workspace_tag(delete_request("delete", &renamed))
.unwrap();
let replacement = create_tag(&backend, "recreate", "new NAME");
assert_ne!(replacement.id, tag.id);
for id in ids {
let ticket = backend.show(id.into()).unwrap();
assert!(ticket.tags.is_empty());
let events: Vec<_> = ticket
.events
.iter()
.filter(|e| e.kind == TicketEventKind::TagsChanged)
.collect();
assert_eq!(events.len(), 2);
let first: Vec<WorkspaceTag> =
serde_json::from_str(&events[0].attributes["tags_after"]).unwrap();
assert_eq!(first, vec![tag.clone()]);
let before_delete: Vec<WorkspaceTag> =
serde_json::from_str(&events[1].attributes["tags_before"]).unwrap();
assert_eq!(before_delete, vec![renamed.clone()]);
assert_eq!(events[1].attributes["tags_after"], "[]");
}
let conn = backend.open_connection().unwrap();
let deleted: i64 = conn
.query_row(
"SELECT deleted FROM workspace_tags WHERE workspace_id='ws' AND tag_id=?1",
params![tag.id],
|row| row.get(0),
)
.unwrap();
assert_eq!(deleted, 1);
assert_eq!(audit_count(&backend), 6);
}
#[test]
fn ticket_tag_set_compares_whole_set_validates_ids_and_replays_historical_result() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
let a = create_tag(&backend, "a", "A");
let b = create_tag(&backend, "b", "B");
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
let request = set_request("set", &[], &[b.clone(), a.clone()]);
let stored = backend
.set_ticket_tags(id.clone().into(), request.clone())
.unwrap();
assert!(stored.windows(2).all(|pair| pair[0].id < pair[1].id));
for bad in [
set_request("bad", std::slice::from_ref(&a), &[]),
TicketTagSetRequest {
expected_tag_ids: stored.iter().map(|t| t.id.clone()).collect(),
tag_ids: vec![a.id.clone(), a.id.clone()],
operation_key: "bad".into(),
},
TicketTagSetRequest {
expected_tag_ids: stored.iter().map(|t| t.id.clone()).collect(),
tag_ids: vec!["missing".into()],
operation_key: "bad".into(),
},
] {
assert!(backend.set_ticket_tags(id.clone().into(), bad).is_err());
}
assert_eq!(backend.show(id.clone().into()).unwrap().tags, stored);
let renamed = rename_tag(&backend, "rename", &a, "Renamed");
backend
.mutate_workspace_tag(delete_request("delete", &renamed))
.unwrap();
assert_eq!(
backend
.set_ticket_tags(id.clone().into(), request.clone())
.unwrap(),
stored
);
let mut changed = request.clone();
changed.tag_ids.reverse();
assert!(matches!(
backend.set_ticket_tags(id.clone().into(), changed),
Err(TicketError::OperationFingerprintMismatch { .. })
));
let current = backend.show(id.clone().into()).unwrap();
assert_eq!(current.tags, vec![b]);
assert_eq!(
current
.events
.iter()
.filter(|e| e.kind == TicketEventKind::TagsChanged)
.count(),
2
);
let other_actor = backend.clone().with_event_attributes(BTreeMap::from([(
"source_worker_id".into(),
"actor-b".into(),
)]));
assert!(matches!(
other_actor.set_ticket_tags(id.into(), request),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert_eq!(audit_count(&backend), 5);
}
#[test]
fn tags_and_operation_keys_are_workspace_scoped() {
let dir = tempdir().unwrap();
let db = dir.path().join("tags.db");
let one = SqliteTicketBackend::open(&db, "one").unwrap();
let two = SqliteTicketBackend::open(&db, "two").unwrap();
let a = create_tag(&one, "same-key", "Same name");
let b = create_tag(&two, "same-key", "Same name");
let ticket = two.create(NewTicket::new("ticket")).unwrap().id;
assert!(
two.mutate_workspace_tag(delete_request("delete", &a))
.is_err()
);
assert!(
two.set_ticket_tags(ticket.clone().into(), set_request("attach", &[], &[a]))
.is_err()
);
two.set_ticket_tags(
ticket.into(),
set_request("attach", &[], std::slice::from_ref(&b)),
)
.unwrap();
assert_eq!(
one.list_workspace_tags(TagListRequest::default())
.unwrap()
.len(),
1
);
assert_eq!(
two.list_workspace_tags(TagListRequest::default()).unwrap(),
vec![b]
);
assert_eq!(audit_count(&one), 1);
}
#[test]
fn tags_do_not_change_content_workflow_queue_targets_or_legacy_labels_in_any_state() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
let tag = create_tag(&backend, "tag", "open");
let closed_tag = create_tag(&backend, "closed-tag", "closed");
for (i, state) in [TicketState::Open, TicketState::Closed]
.into_iter()
.enumerate()
{
let mut new = NewTicket::new(format!("ticket {i}"));
new.workflow_state = Some(state);
new.labels = vec!["legacy label".into()];
new.queued_by = Some("user".into());
new.queued_at = Some("yesterday".into());
let id = backend.create(new).unwrap().id;
let before = backend.show(id.clone().into()).unwrap();
backend
.set_ticket_tags(
id.clone().into(),
set_request(&format!("set-{i}"), &[], &[tag.clone(), closed_tag.clone()]),
)
.unwrap();
let after = backend.show(id.clone().into()).unwrap();
assert_eq!(before.meta, after.meta);
assert_eq!(before.document, after.document);
assert_eq!(before.resolution, after.resolution);
assert_eq!(
ticket_content_digest(&before),
ticket_content_digest(&after)
);
assert_eq!(before.closure_operation_key, after.closure_operation_key);
assert_eq!(before.current_closure, after.current_closure);
assert_eq!(before.case_history, after.case_history);
assert_eq!(
before.legacy_workflow_snapshot,
after.legacy_workflow_snapshot
);
let mut expected_tags = vec![tag.clone(), closed_tag.clone()];
expected_tags.sort_by(|a, b| a.id.cmp(&b.id));
assert_eq!(after.tags, expected_tags);
let summary = backend
.list(TicketListQuery::all())
.unwrap()
.into_iter()
.find(|s| s.id == id)
.unwrap();
assert_eq!(summary.tags, after.tags);
let projection = backend.list_workspace_projection(200).unwrap();
assert_eq!(
projection
.items
.iter()
.find(|s| s.summary.id == id)
.unwrap()
.summary
.tags,
after.tags
);
let page = backend
.list_workspace_projection_page(SqliteTicketListPageQuery {
limit: 200,
..Default::default()
})
.unwrap();
assert_eq!(
page.items
.iter()
.find(|s| s.summary.id == id)
.unwrap()
.summary
.tags,
after.tags
);
}
}
#[test]
fn tag_list_has_bounded_id_keyset_order_and_excludes_deleted() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
// Fixture insertion avoids spending this query test on 205 mutation transactions.
backend
.with_write(|conn| {
for i in 0..205 {
conn.execute(
"INSERT INTO workspace_tags VALUES ('ws',?1,?1,?1,'fixture',0)",
params![format!("tag-{i:03}")],
)
.map_err(sqlite_err)?;
}
Ok(())
})
.unwrap();
let first = backend
.list_workspace_tags(TagListRequest {
after: None,
limit: Some(usize::MAX),
})
.unwrap();
assert_eq!(first.len(), 200);
assert_eq!(first[0].id, "tag-000");
assert_eq!(first[199].id, "tag-199");
assert_eq!(
backend
.list_workspace_tags(TagListRequest::default())
.unwrap(),
first
);
backend
.mutate_workspace_tag(delete_request("delete", &first[0]))
.unwrap();
let tail = backend
.list_workspace_tags(TagListRequest {
after: Some("tag-199".into()),
limit: Some(200),
})
.unwrap();
assert_eq!(tail.len(), 5);
assert!(
backend
.list_workspace_tags(TagListRequest {
after: None,
limit: Some(0)
})
.unwrap()
.is_empty()
);
assert!(
backend
.list_workspace_tags(TagListRequest {
after: None,
limit: Some(1)
})
.unwrap()
.iter()
.all(|t| t.id == "tag-001")
);
}
#[test]
fn failed_tag_mutation_hook_rolls_back_links_tombstone_audit_and_receipt() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
let tag = create_tag(&backend, "create", "Tag");
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
let failing = backend.clone().with_mutation_hook(Arc::new(|_, _| {
Err(TicketError::Conflict("test hook".into()))
}));
let attach = set_request("attach", &[], std::slice::from_ref(&tag));
assert!(
failing
.set_ticket_tags(id.clone().into(), attach.clone())
.is_err()
);
assert!(backend.show(id.clone().into()).unwrap().tags.is_empty());
assert_eq!(audit_count(&backend), 1);
backend.set_ticket_tags(id.clone().into(), attach).unwrap();
let before = backend.show(id.clone().into()).unwrap();
let deletion = delete_request("delete", &tag);
assert!(failing.mutate_workspace_tag(deletion.clone()).is_err());
assert_eq!(backend.show(id.into()).unwrap(), before);
assert_eq!(
backend
.list_workspace_tags(TagListRequest::default())
.unwrap(),
vec![tag]
);
assert_eq!(audit_count(&backend), 2);
backend.mutate_workspace_tag(deletion).unwrap();
assert_eq!(audit_count(&backend), 3);
}
#[test]
fn concurrent_tag_replay_and_delete_attach_are_serialized() {
use std::sync::Barrier;
use std::thread;
let dir = tempdir().unwrap();
let db = dir.path().join("tags.db");
let backend = SqliteTicketBackend::open(&db, "ws").unwrap();
let barrier = Arc::new(Barrier::new(3));
let mut joins = Vec::new();
for _ in 0..2 {
let backend = backend.clone();
let barrier = barrier.clone();
joins.push(thread::spawn(move || {
barrier.wait();
create_tag(&backend, "create", "Tag")
}));
}
barrier.wait();
let tag = joins.remove(0).join().unwrap();
assert_eq!(joins.remove(0).join().unwrap(), tag);
assert_eq!(audit_count(&backend), 1);
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
let barrier = Arc::new(Barrier::new(3));
let attach = {
let backend = backend.clone();
let barrier = barrier.clone();
let tag = tag.clone();
let id = id.clone();
thread::spawn(move || {
barrier.wait();
backend.set_ticket_tags(id.into(), set_request("attach", &[], &[tag]))
})
};
let delete = {
let backend = backend.clone();
let barrier = barrier.clone();
let tag = tag.clone();
thread::spawn(move || {
barrier.wait();
backend.mutate_workspace_tag(delete_request("delete", &tag))
})
};
barrier.wait();
let attached = attach.join().unwrap();
assert!(delete.join().unwrap().unwrap().deleted);
assert!(backend.show(id.into()).unwrap().tags.is_empty());
assert!(
backend
.list_workspace_tags(TagListRequest::default())
.unwrap()
.is_empty()
);
assert_eq!(audit_count(&backend), if attached.is_ok() { 3 } else { 2 });
let links: i64 = backend
.open_connection()
.unwrap()
.query_row("SELECT COUNT(*) FROM typed_ticket_tags", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(links, 0);
}
#[test]
fn ticket_tag_receipts_bind_selector_and_operation_kind_and_replay_before_assignment_fence() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
let tag = create_tag(&backend, "create", "Tag");
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
let request = set_request("attach", &[], std::slice::from_ref(&tag));
let mut reused = request.clone();
reused.operation_key = "create".into();
assert!(matches!(
backend.set_ticket_tags(id.clone().into(), reused),
Err(TicketError::OperationFingerprintMismatch { .. })
));
let conn = backend.open_connection().unwrap();
conn.execute_batch("CREATE TABLE ticket_active_worker_assignments (workspace_id TEXT,ticket_id TEXT,runtime_id TEXT,worker_id TEXT,assignment_id TEXT);").unwrap();
conn.execute("INSERT INTO ticket_active_worker_assignments VALUES ('ws',?1,'runtime','worker','assignment')",params![id]).unwrap();
let worker = backend.clone().with_event_attributes(BTreeMap::from([
("source_actor_role".into(), "ticket_worker".into()),
("source_runtime_id".into(), "runtime".into()),
("source_worker_id".into(), "worker".into()),
("source_assignment_id".into(), "assignment".into()),
]));
let stored = worker
.set_ticket_tags(TicketIdOrSlug::Id(id.clone()), request.clone())
.unwrap();
conn.execute("DELETE FROM ticket_active_worker_assignments", [])
.unwrap();
assert_eq!(
worker
.set_ticket_tags(TicketIdOrSlug::Id(id.clone()), request.clone())
.unwrap(),
stored
);
assert!(matches!(
worker.set_ticket_tags(TicketIdOrSlug::Query(id.clone()), request),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert!(
worker
.set_ticket_tags(
id.clone().into(),
set_request("new-operation", &stored, &[])
)
.is_err()
);
assert_eq!(backend.show(id.clone().into()).unwrap().tags, stored);
backend
.mutate_workspace_tag(delete_request("delete", &tag))
.unwrap();
assert!(
backend
.set_ticket_tags(
id.into(),
set_request("deleted-attach", &[], std::slice::from_ref(&tag))
)
.is_err()
);
assert_eq!(audit_count(&backend), 3);
}
#[test]
fn tag_domain_dispatch_and_wire_contracts_are_exact() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
let request = WorkspaceTagMutation {
operation_key: "create".into(),
action: WorkspaceTagAction::Create {
name: "Name".into(),
},
};
assert_eq!(
serde_json::to_value(&request).unwrap(),
serde_json::json!({"operation_key":"create","action":{"kind":"create","name":"Name"}})
);
let operation = TicketBackendOperation::MutateWorkspaceTag { request };
let operation = serde_json::from_value(serde_json::to_value(operation).unwrap()).unwrap();
let TicketBackendOperationResult::WorkspaceTag(tag) =
execute_ticket_backend_operation(&backend, operation).unwrap()
else {
panic!("tag result")
};
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
let result = execute_ticket_backend_operation(
&backend,
TicketBackendOperation::SetTicketTags {
id: id.clone().into(),
request: set_request("set", &[], std::slice::from_ref(&tag)),
},
)
.unwrap();
assert!(
matches!(result,TicketBackendOperationResult::WorkspaceTags(tags) if tags==vec![tag.clone()])
);
let result = execute_ticket_backend_operation(
&backend,
TicketBackendOperation::ListWorkspaceTags {
request: TagListRequest::default(),
},
)
.unwrap();
assert!(matches!(result,TicketBackendOperationResult::WorkspaceTags(tags) if tags==vec![tag]));
let ticket = backend.show(id.into()).unwrap();
let mut wire = serde_json::to_value(&ticket).unwrap();
wire.as_object_mut().unwrap().remove("tags");
assert!(
serde_json::from_value::<Ticket>(wire)
.unwrap()
.tags
.is_empty()
);
let summary = backend.list(TicketListQuery::all()).unwrap().remove(0);
let mut wire = serde_json::to_value(summary).unwrap();
wire.as_object_mut().unwrap().remove("tags");
assert!(
serde_json::from_value::<TicketSummary>(wire)
.unwrap()
.tags
.is_empty()
);
let schema = schemars::schema_for!(WorkspaceTagAction);
assert!(
serde_json::to_string(&schema)
.unwrap()
.contains("expected_last_operation_key")
);
}
#[test]
fn unchanged_tag_set_records_receipt_without_claiming_a_membership_change() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
let before = backend.show(id.clone().into()).unwrap();
let request = set_request("noop", &[], &[]);
assert!(
backend
.set_ticket_tags(id.clone().into(), request.clone())
.unwrap()
.is_empty()
);
assert!(
backend
.set_ticket_tags(id.clone().into(), request)
.unwrap()
.is_empty()
);
assert_eq!(
backend.show(id.clone().into()).unwrap().events,
before.events
);
assert_eq!(audit_count(&backend), 1);
for request in [
TicketTagSetRequest {
expected_tag_ids: vec!["x".into(); 101],
tag_ids: vec![],
operation_key: "large-observation".into(),
},
TicketTagSetRequest {
expected_tag_ids: vec![],
tag_ids: (0..101).map(|i| format!("tag-{i}")).collect(),
operation_key: "large-desired".into(),
},
TicketTagSetRequest {
expected_tag_ids: vec![],
tag_ids: vec!["x".repeat(129)],
operation_key: "large-id".into(),
},
] {
assert!(backend.set_ticket_tags(id.clone().into(), request).is_err());
assert_eq!(
backend.show(id.clone().into()).unwrap().events,
before.events
);
assert_eq!(audit_count(&backend), 1);
}
}
#[test]
fn tag_receipt_preserves_original_context_but_reassignment_does_not_change_request_identity() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
let original_context = BTreeMap::from([
("source_runtime_id".into(), "runtime".into()),
("source_worker_id".into(), "worker".into()),
("source_actor_role".into(), "ticket_worker".into()),
("source_assignment_id".into(), "assignment-a".into()),
]);
let original = backend
.clone()
.with_event_attributes(original_context.clone());
let request = WorkspaceTagMutation {
operation_key: "create".into(),
action: WorkspaceTagAction::Create {
name: "ready".into(),
},
};
let tag = original.mutate_workspace_tag(request.clone()).unwrap();
let reassigned = backend.clone().with_event_attributes(BTreeMap::from([
("source_runtime_id".into(), "runtime".into()),
("source_worker_id".into(), "worker".into()),
("source_actor_role".into(), "contributor".into()),
]));
assert_eq!(reassigned.mutate_workspace_tag(request).unwrap(), tag);
let stored: String = backend.with_read(|conn| conn.query_row("SELECT source_context_json FROM workspace_tag_operations WHERE workspace_id='ws' AND operation_key='create'", [], |row| row.get(0)).map_err(sqlite_err)).unwrap();
assert_eq!(
serde_json::from_str::<BTreeMap<String, String>>(&stored).unwrap(),
original_context
);
assert_eq!(audit_count(&backend), 1);
}
+22
View File
@@ -242,6 +242,28 @@ impl From<crate::SqliteTicketBackend> for TicketToolBackend {
}
impl TicketBackend for TicketToolBackend {
fn list_workspace_tags(
&self,
request: crate::TagListRequest,
) -> TicketResult<Vec<crate::WorkspaceTag>> {
self.backend.list_workspace_tags(request)
}
fn mutate_workspace_tag(
&self,
request: crate::WorkspaceTagMutation,
) -> TicketResult<crate::WorkspaceTag> {
self.backend.mutate_workspace_tag(request)
}
fn set_ticket_tags(
&self,
id: TicketIdOrSlug,
request: crate::TicketTagSetRequest,
) -> TicketResult<Vec<crate::WorkspaceTag>> {
self.backend.set_ticket_tags(id, request)
}
fn default_intake_ready_state_change_body(&self, from: &str) -> String {
self.backend.default_intake_ready_state_change_body(from)
}
+38
View File
@@ -0,0 +1,38 @@
-- Frozen standalone component8 tag extension; combine with schema7.sql.
-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade.
CREATE TABLE workspace_tags (
workspace_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
name TEXT NOT NULL,
normalized_name TEXT NOT NULL,
last_operation_key TEXT NOT NULL,
deleted INTEGER NOT NULL CHECK (deleted IN (0, 1)),
PRIMARY KEY (workspace_id, tag_id)
);
CREATE UNIQUE INDEX workspace_tags_active_name
ON workspace_tags(workspace_id, normalized_name) WHERE deleted = 0;
CREATE TABLE typed_ticket_tags (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, tag_id),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, tag_id) REFERENCES workspace_tags(workspace_id, tag_id) ON DELETE RESTRICT
);
CREATE INDEX typed_ticket_tags_workspace_tag
ON typed_ticket_tags(workspace_id, tag_id, ticket_id);
-- Immutable operation receipt and Workspace audit: exact input includes actor
-- attributes; before/result preserve stable identities and display-name snapshots.
-- No foreign keys to mutable rows: history survives deletion of current links.
CREATE TABLE workspace_tag_operations (
workspace_id TEXT NOT NULL,
operation_key TEXT NOT NULL,
kind TEXT NOT NULL,
target_id TEXT NOT NULL,
input_json TEXT NOT NULL,
source_context_json TEXT NOT NULL,
before_json TEXT NOT NULL,
result_json TEXT NOT NULL,
at TEXT NOT NULL,
PRIMARY KEY (workspace_id, operation_key)
);
+33
View File
@@ -0,0 +1,33 @@
-- Frozen standalone component9 case extension; combine with schema7.sql.
-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority.
CREATE TABLE typed_ticket_case_metadata (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT,
PRIMARY KEY (workspace_id, ticket_id),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_case_history (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL,
operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, operation_key),
UNIQUE (workspace_id, ticket_id, ordinal),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_legacy_workflow_snapshot (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets
WHEN NEW.workflow_state NOT IN ('open', 'closed')
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets
WHEN NEW.workflow_state NOT IN ('open', 'closed')
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
+569 -3
View File
@@ -1,5 +1,5 @@
//! Adopted compatibility contract: canonical standalone schema7 six-state snapshots
//! migrate independently to schema9, preserving original evidence and routing handoff.
//! compose into schema10, preserving original evidence and routing handoff.
use rusqlite::{Connection, params};
use tempfile::TempDir;
use ticket::*;
@@ -78,7 +78,7 @@ fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_withou
row.get(0)
})
.unwrap();
assert_eq!(marker, 9);
assert_eq!(marker, 10);
let after_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
assert_eq!(after_events, historical_events);
let store = SqliteTicketBackend::open_verified(&path, "workspace").unwrap();
@@ -234,7 +234,7 @@ fn unknown_legacy_state_rolls_back_the_independent_case_migration() {
}
#[test]
fn schema8_tag_composition_is_rejected_until_explicitly_integrated() {
fn falsely_numbered_schema8_without_tags_is_rejected_without_writes() {
let connection = Connection::open_in_memory().unwrap();
schema7(&connection);
connection
@@ -250,3 +250,569 @@ fn schema8_tag_composition_is_rejected_until_explicitly_integrated() {
8
);
}
// Read actual SQLite values, not merely counts: receipt JSON, original context,
// name snapshots and event/reference/attribute rows must survive byte-for-byte.
fn rows(connection: &Connection, table: &str) -> Vec<Vec<rusqlite::types::Value>> {
let mut stmt = connection
.prepare(&format!("SELECT * FROM {table} ORDER BY rowid"))
.unwrap();
let count = stmt.column_count();
stmt.query_map([], |row| (0..count).map(|i| row.get(i)).collect())
.unwrap()
.collect::<std::result::Result<_, _>>()
.unwrap()
}
fn copy_tables(connection: &Connection, source: &std::path::Path, tables: &[&str]) {
connection
.execute(
"ATTACH DATABASE ?1 AS fixture_source",
[source.to_str().unwrap()],
)
.unwrap();
for table in tables {
connection
.execute_batch(&format!(
"INSERT INTO {table} SELECT * FROM fixture_source.{table}"
))
.unwrap();
}
connection
.execute_batch("DETACH DATABASE fixture_source")
.unwrap();
}
fn mutate(backend: &SqliteTicketBackend, key: &str, action: WorkspaceTagAction) -> WorkspaceTag {
backend
.mutate_workspace_tag(WorkspaceTagMutation {
operation_key: key.into(),
action,
})
.unwrap()
}
fn case_request(ticket: &Ticket, key: &str, action: TicketCaseAction) -> TicketCaseUpdate {
TicketCaseUpdate {
expected_content_digest: ticket_content_digest(ticket),
expected_state: ticket.meta.workflow_state,
expected_closure_operation_key: ticket.closure_operation_key.clone(),
operation_key: key.into(),
action,
explanation: "Retain this judgment".into(),
references: vec![TicketReference {
kind: "result".into(),
target: "retained-result".into(),
}],
author: Some("authenticated-user".into()),
}
}
#[test]
fn tagged_schema8_composes_cases_preserving_tombstones_membership_context_receipts_and_thread() {
let temp = TempDir::new().unwrap();
let source_path = temp.path().join("tag-operations.db");
let source = SqliteTicketBackend::open(&source_path, "workspace")
.unwrap()
.with_event_attributes(std::collections::BTreeMap::from([
("source_account_id".into(), "account".into()),
("source_user_id".into(), "user".into()),
("source_actor_role".into(), "human".into()),
]));
let id = source
.create(NewTicket::new("historical tagged ticket"))
.unwrap()
.id;
// Generate real durable T-740 receipts/audit/event snapshots through its
// unchanged classification boundary, then seed only those rows into frozen8.
let create = WorkspaceTagMutation {
operation_key: "create".into(),
action: WorkspaceTagAction::Create {
name: "open".into(),
},
};
let original = source.mutate_workspace_tag(create.clone()).unwrap();
let attach = TicketTagSetRequest {
operation_key: "attach".into(),
expected_tag_ids: vec![],
tag_ids: vec![original.id.clone()],
};
let attach_result = source
.set_ticket_tags(id.clone().into(), attach.clone())
.unwrap();
let renamed = mutate(
&source,
"rename",
WorkspaceTagAction::Rename {
tag_id: original.id.clone(),
expected_last_operation_key: "create".into(),
name: "closed".into(),
},
);
let deleted = mutate(
&source,
"delete",
WorkspaceTagAction::Delete {
tag_id: renamed.id.clone(),
expected_last_operation_key: "rename".into(),
},
);
let recreated = mutate(
&source,
"recreate",
WorkspaceTagAction::Create {
name: "closed".into(),
},
);
assert_ne!(recreated.id, deleted.id);
source
.set_ticket_tags(
id.clone().into(),
TicketTagSetRequest {
operation_key: "attach-recreated".into(),
expected_tag_ids: vec![],
tag_ids: vec![recreated.id.clone()],
},
)
.unwrap();
let path = temp.path().join("retained8.db");
let connection = Connection::open(&path).unwrap();
connection
.pragma_update(None, "foreign_keys", "ON")
.unwrap();
schema7(&connection);
connection
.execute_batch(include_str!("fixtures/schema8_tags.sql"))
.unwrap();
connection
.execute("UPDATE ticket_schema_migrations SET version=8", [])
.unwrap();
let preserved = [
"workspace_tags",
"typed_ticket_tags",
"workspace_tag_operations",
"typed_ticket_events",
"typed_ticket_event_attributes",
"typed_ticket_event_references",
];
copy_tables(&connection, &source_path, &["typed_tickets"]);
copy_tables(&connection, &source_path, &preserved);
// Six-state retained data, not a current10 DB with its number changed.
connection.execute("UPDATE typed_tickets SET workflow_state='done', status='done', resolution='Historical explanation', queued_by='old-router',queued_at='old-time'", []).unwrap();
connection.execute("INSERT INTO typed_ticket_events (workspace_id,ticket_id,event_index,kind,author,body) VALUES ('workspace',?1,99,'close','old-user','Original close')", [&id]).unwrap();
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace',?1,99,'operation_key','old-close-receipt')", [&id]).unwrap();
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace',?1,99,'request_fingerprint','old-close-fingerprint')", [&id]).unwrap();
let before: Vec<_> = preserved.iter().map(|t| rows(&connection, t)).collect();
verify_legacy_sqlite_ticket_schema(&connection).unwrap();
assert!(verify_sqlite_ticket_schema(&connection).is_err());
assert!(verify_pre_classification_ticket_schema(&connection).is_err());
migrate_sqlite_ticket_schema(&connection).unwrap();
verify_sqlite_ticket_schema(&connection).unwrap();
assert_eq!(
rows(&connection, "ticket_schema_migrations")[0][0],
rusqlite::types::Value::Integer(10)
);
for (table, expected) in preserved.iter().zip(&before) {
assert_eq!(&rows(&connection, table), expected, "changed {table}");
}
let backend = SqliteTicketBackend::open_verified(&path, "workspace")
.unwrap()
.with_event_attributes(std::collections::BTreeMap::from([
("source_account_id".into(), "account".into()),
("source_user_id".into(), "user".into()),
("source_actor_role".into(), "human".into()),
]));
let ticket = backend.show(id.clone().into()).unwrap();
assert_eq!(ticket.tags, vec![recreated]);
assert_eq!(
ticket_content_digest(&ticket),
ticket_content_digest(&source.show(id.clone().into()).unwrap())
);
assert!(matches!(
backend.update_case(
&id,
case_request(&ticket, "old-close-receipt", TicketCaseAction::Reopen)
),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert_eq!(
ticket.current_closure.as_ref().unwrap().reason,
TicketClosureReason::Unknown
);
assert_eq!(
ticket.current_closure.as_ref().unwrap().explanation,
"Historical explanation"
);
assert_eq!(
ticket
.legacy_workflow_snapshot
.as_ref()
.unwrap()
.workflow_state,
"done"
);
assert_eq!(
backend.mutate_workspace_tag(create.clone()).unwrap(),
original
);
assert_eq!(
backend
.set_ticket_tags(id.clone().into(), attach.clone())
.unwrap(),
attach_result
);
let other = backend
.clone()
.with_event_attributes(std::collections::BTreeMap::from([
("source_account_id".into(), "other-account".into()),
("source_user_id".into(), "user".into()),
]));
assert!(matches!(
other.mutate_workspace_tag(create),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert!(matches!(
other.set_ticket_tags(id.clone().into(), attach),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert!(
backend
.set_ticket_tags(
id.clone().into(),
TicketTagSetRequest {
operation_key: "attach-deleted".into(),
expected_tag_ids: ticket.tags.iter().map(|t| t.id.clone()).collect(),
tag_ids: vec![deleted.id.clone()],
}
)
.is_err()
);
assert!(
connection
.execute(
"INSERT INTO typed_ticket_tags VALUES ('foreign',?1,?2)",
params![id, deleted.id]
)
.is_err()
);
let judgments = rows(&connection, "typed_ticket_case_history");
migrate_sqlite_ticket_schema(&connection).unwrap();
assert_eq!(rows(&connection, "typed_ticket_case_history"), judgments);
for (table, expected) in preserved.iter().zip(&before) {
assert_eq!(
&rows(&connection, table),
expected,
"replay changed {table}"
);
}
let reopened = backend
.update_case(
&id,
case_request(&ticket, "reopen", TicketCaseAction::Reopen),
)
.unwrap();
assert_eq!(reopened.tags, ticket.tags);
assert_eq!(reopened.case_history[0], ticket.case_history[0]);
assert_eq!(
ticket_content_digest(&reopened),
ticket_content_digest(&ticket)
);
assert!(reopened.current_closure.is_none());
let fk_failures: i64 = connection
.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(fk_failures, 0);
}
#[test]
fn independent_case_schema9_adds_only_tags_without_reapplying_judgments_or_receipts() {
let temp = TempDir::new().unwrap();
let source_path = temp.path().join("case-operations.db");
let source = SqliteTicketBackend::open(&source_path, "workspace")
.unwrap()
.with_event_attributes(std::collections::BTreeMap::from([
("source_runtime_id".into(), "runtime".into()),
("source_worker_id".into(), "worker".into()),
]));
let id = source.create(NewTicket::new("existing case")).unwrap().id;
let before = source.show(id.clone().into()).unwrap();
let close = case_request(
&before,
"close",
TicketCaseAction::Close {
reason: TicketClosureReason::Duplicate,
},
);
let closed = source.update_case(&id, close.clone()).unwrap();
let corrected = source
.update_case(
&id,
case_request(
&closed,
"correct",
TicketCaseAction::CorrectClosure {
reason: TicketClosureReason::NotPlanned,
},
),
)
.unwrap();
let reopened = source
.update_case(
&id,
case_request(&corrected, "reopen", TicketCaseAction::Reopen),
)
.unwrap();
let path = temp.path().join("retained9.db");
let connection = Connection::open(&path).unwrap();
schema7(&connection);
connection
.execute_batch(include_str!("fixtures/schema9_cases.sql"))
.unwrap();
connection
.execute("UPDATE ticket_schema_migrations SET version=9", [])
.unwrap();
let tables = [
"typed_tickets",
"typed_ticket_case_metadata",
"typed_ticket_case_history",
"typed_ticket_legacy_workflow_snapshot",
"typed_ticket_events",
"typed_ticket_event_attributes",
"typed_ticket_event_references",
"workspace_resource_keys",
"workspace_resource_key_counters",
];
copy_tables(&connection, &source_path, &tables);
// A migrated closed case already judged Unknown by standalone9 must remain
// exactly that judgment, with its original migration actor/key and handoff.
connection.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,resolution) VALUES ('workspace','old9','old9','Old migrated case','closed','task','P2','criteria','closed',1,'Original legacy close')", []).unwrap();
let snapshot = TicketLegacyWorkflowSnapshot {
workflow_state: "done".into(),
workflow_state_explicit: true,
status: "done".into(),
queued_by: Some("original-router".into()),
queued_at: Some("original-time".into()),
resolution: Some("Original legacy close".into()),
};
let judgment = TicketCaseHistoryEntry {
operation_key: "migration-v9:old9".into(),
previous_closure_operation_key: None,
action: TicketCaseAction::Close {
reason: TicketClosureReason::Unknown,
},
explanation: "Original legacy close".into(),
references: vec![],
author: "ticket-schema-migration-v9".into(),
at: "original-migration-time".into(),
};
connection
.execute(
"INSERT INTO typed_ticket_legacy_workflow_snapshot VALUES ('workspace','old9',?1)",
[serde_json::to_string(&snapshot).unwrap()],
)
.unwrap();
connection.execute("INSERT INTO typed_ticket_case_history VALUES ('workspace','old9',0,'migration-v9:old9','migration-v9',?1)", [serde_json::to_string(&judgment).unwrap()]).unwrap();
connection.execute("INSERT INTO typed_ticket_case_metadata VALUES ('workspace','old9','migration-v9:old9')", []).unwrap();
let original: Vec<_> = tables.iter().map(|t| rows(&connection, t)).collect();
migrate_sqlite_ticket_schema(&connection).unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
for (table, expected) in tables.iter().zip(&original) {
assert_eq!(
&rows(&connection, table),
expected,
"changed independent9 {table}"
);
}
assert!(rows(&connection, "workspace_tags").is_empty());
let backend = SqliteTicketBackend::open_verified(&path, "workspace")
.unwrap()
.with_event_attributes(std::collections::BTreeMap::from([
("source_runtime_id".into(), "runtime".into()),
("source_worker_id".into(), "worker".into()),
]));
let old9 = backend.show("old9".into()).unwrap();
assert_eq!(old9.case_history, vec![judgment]);
assert_eq!(old9.legacy_workflow_snapshot, Some(snapshot));
assert_eq!(
old9.current_closure.unwrap().reason,
TicketClosureReason::Unknown
);
assert_eq!(backend.show(id.clone().into()).unwrap(), reopened);
assert_eq!(backend.update_case(&id, close.clone()).unwrap(), reopened);
let other = backend
.clone()
.with_event_attributes(std::collections::BTreeMap::from([
("source_runtime_id".into(), "runtime".into()),
("source_worker_id".into(), "other-worker".into()),
]));
assert!(matches!(
other.update_case(&id, close),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert_eq!(rows(&connection, "typed_ticket_case_history"), original[2]);
}
#[test]
fn migration_inside_host_transaction_is_resumable_and_never_commits_host_writes() {
let connection = Connection::open_in_memory().unwrap();
schema7(&connection);
verify_pre_classification_ticket_schema(&connection).unwrap();
assert!(migrate_sqlite_ticket_schema_in_transaction(&connection).is_err());
connection.execute_batch("CREATE TABLE host_work (value TEXT); BEGIN IMMEDIATE; INSERT INTO host_work VALUES ('pending');").unwrap();
migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap();
verify_sqlite_ticket_schema(&connection).unwrap();
assert!(!connection.is_autocommit());
connection.execute_batch("ROLLBACK").unwrap();
verify_pre_classification_ticket_schema(&connection).unwrap();
assert!(rows(&connection, "host_work").is_empty());
connection.execute_batch("BEGIN IMMEDIATE").unwrap();
migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap();
migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap();
connection.execute_batch("COMMIT").unwrap();
verify_sqlite_ticket_schema(&connection).unwrap();
}
#[test]
fn falsely_numbered_schema9_and_weakened_immutable_triggers_are_rejected_without_migration() {
for drift in [
"UPDATE ticket_schema_migrations SET version=9",
"UPDATE ticket_schema_migrations SET version=9; DROP TRIGGER typed_ticket_case_history_no_update; CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history BEGIN SELECT 1; END;",
] {
let connection = Connection::open_in_memory().unwrap();
schema7(&connection);
if drift.contains("DROP TRIGGER") {
connection
.execute_batch(include_str!("fixtures/schema9_cases.sql"))
.unwrap();
}
connection.execute_batch(drift).unwrap();
let marker = rows(&connection, "ticket_schema_migrations");
assert!(migrate_sqlite_ticket_schema(&connection).is_err());
assert_eq!(rows(&connection, "ticket_schema_migrations"), marker);
let tags: i64 = connection
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE name='workspace_tags'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(tags, 0);
}
}
#[test]
fn failed_component_conversion_rolls_back_its_ddl_and_keeps_host_transaction_retryable() {
let connection = Connection::open_in_memory().unwrap();
schema7(&connection);
connection.execute_batch("CREATE TABLE host_work (value TEXT);
INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
VALUES ('workspace','ticket','ticket','Case','open','task','P2','criteria','ready',1);
CREATE TRIGGER host_abort_case_update BEFORE UPDATE OF workflow_state ON typed_tickets
BEGIN SELECT RAISE(ABORT,'host failure'); END;
BEGIN IMMEDIATE; INSERT INTO host_work VALUES ('retained host work');").unwrap();
assert!(migrate_sqlite_ticket_schema_in_transaction(&connection).is_err());
assert!(!connection.is_autocommit());
verify_pre_classification_ticket_schema(&connection).unwrap();
assert_eq!(rows(&connection, "host_work").len(), 1);
let tags: i64 = connection
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE name='workspace_tags'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(tags, 0, "DDL before failed state conversion must roll back");
connection
.execute_batch("DROP TRIGGER host_abort_case_update")
.unwrap();
migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap();
connection.execute_batch("COMMIT").unwrap();
verify_sqlite_ticket_schema(&connection).unwrap();
assert_eq!(rows(&connection, "host_work").len(), 1);
}
#[test]
fn retained_workspace_foreign_keys_survive_composition_without_rebuilding_ticket_or_tags() {
let connection = Connection::open_in_memory().unwrap();
connection
.pragma_update(None, "foreign_keys", "ON")
.unwrap();
connection.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY); INSERT INTO workspaces VALUES ('workspace');").unwrap();
// Ticket accepts stricter host-owned Workspace references and must preserve
// them, not rebuild parents against its weaker standalone authority schema.
let schema = include_str!("fixtures/schema7.sql");
let (before, ticket) = schema.split_once("CREATE TABLE typed_tickets (").unwrap();
let (ticket, after) = ticket
.split_once("CREATE TABLE typed_ticket_targets (")
.unwrap();
let ticket = ticket.replace("PRIMARY KEY (workspace_id, ticket_id)",
"PRIMARY KEY (workspace_id, ticket_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE");
connection
.execute_batch(&format!(
"{before}CREATE TABLE typed_tickets ({ticket}CREATE TABLE typed_ticket_targets ({after}"
))
.unwrap();
let tags = include_str!("fixtures/schema8_tags.sql")
.replace("PRIMARY KEY (workspace_id, tag_id)", "PRIMARY KEY (workspace_id, tag_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE")
.replace("PRIMARY KEY (workspace_id, operation_key)", "PRIMARY KEY (workspace_id, operation_key), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE");
connection.execute_batch(&tags).unwrap();
connection.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL);
INSERT INTO ticket_schema_migrations VALUES(8,'ticket schema baseline','retained');
INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
VALUES('workspace','ticket','ticket','Case','done','task','P2','criteria','done',1);
INSERT INTO workspace_tags VALUES('workspace','tag','closed','closed','create',0);
INSERT INTO typed_ticket_tags VALUES('workspace','ticket','tag');").unwrap();
let signature = |table| {
let mut stmt = connection
.prepare(&format!(
"SELECT * FROM pragma_foreign_key_list('{table}') ORDER BY id,seq"
))
.unwrap();
let count = stmt.column_count();
stmt.query_map([], |row| {
(0..count)
.map(|i| row.get::<_, rusqlite::types::Value>(i))
.collect::<rusqlite::Result<Vec<_>>>()
})
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
};
let tables = [
"typed_tickets",
"workspace_tags",
"workspace_tag_operations",
"typed_ticket_tags",
];
let before: Vec<_> = tables.iter().map(|t| signature(t)).collect();
verify_legacy_sqlite_ticket_schema(&connection).unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
for (table, expected) in tables.iter().zip(before) {
assert_eq!(signature(table), expected, "lost host FK on {table}");
}
assert!(
connection
.execute(
"INSERT INTO workspace_tags VALUES('foreign','tag','open','open','create',0)",
[]
)
.is_err()
);
assert!(
connection
.execute(
"INSERT INTO typed_ticket_tags VALUES('foreign','ticket','tag')",
[]
)
.is_err()
);
let failures: i64 = connection
.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(failures, 0);
}