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
+40 -2
View File
@@ -391,6 +391,21 @@ impl SqliteWorkspaceAuthority {
.collect::<Vec<_>>();
predicates.push(format!("t.workflow_state IN ({})", states.join(",")));
}
for id in &query.tag_ids {
let id = bind(SqlValue::Text(id.clone()));
predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_tags l JOIN workspace_tags tag ON tag.workspace_id=l.workspace_id AND tag.tag_id=l.tag_id WHERE l.workspace_id=t.workspace_id AND l.ticket_id=t.ticket_id AND l.tag_id={id} AND tag.deleted=0)"));
}
for (ids, negate) in [(&query.tag_ids_any, false), (&query.tag_ids_none, true)] {
if !ids.is_empty() {
let ids = ids
.iter()
.map(|id| bind(SqlValue::Text(id.clone())))
.collect::<Vec<_>>()
.join(",");
let not = if negate { "NOT " } else { "" };
predicates.push(format!("{not}EXISTS (SELECT 1 FROM typed_ticket_tags l JOIN workspace_tags tag ON tag.workspace_id=l.workspace_id AND tag.tag_id=l.tag_id WHERE l.workspace_id=t.workspace_id AND l.ticket_id=t.ticket_id AND l.tag_id IN ({ids}) AND tag.deleted=0)"));
}
}
if let Some(text) = query.query.as_deref().filter(|text| !text.is_empty()) {
let pattern = bind(SqlValue::Text(format!("%{}%", text.to_lowercase())));
predicates.push(format!(
@@ -908,6 +923,7 @@ impl SqliteWorkspaceAuthority {
queued_at: ticket.meta.queued_at,
targets,
risk_flags: ticket.meta.risk_flags,
tags: ticket.tags,
body,
body_truncated,
event_count: ticket.events.len(),
@@ -1954,6 +1970,13 @@ fn ticket_evidence_summary(
}
fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> {
for ids in [&query.tag_ids, &query.tag_ids_any, &query.tag_ids_none] {
if ids.len() > 100 || ids.iter().any(|id| id.is_empty() || id.len() > 128) {
return Err(Error::InvalidInput(
"tag filters require at most 100 nonempty stable IDs of at most 128 bytes".into(),
));
}
}
for evidence in &query.evidence {
if !matches!(
evidence.as_str(),
@@ -2142,7 +2165,19 @@ fn ticket_matches_query(
authoritative_events: &[TicketEvent],
query: &TicketQueryRequest,
) -> bool {
if !query.states.is_empty() && !query.states.iter().any(|state| state == &summary.state) {
let has_tag = |id: &String| detail.tags.iter().any(|tag| &tag.id == id);
if !query.tag_ids.iter().all(has_tag)
|| (!query.tag_ids_any.is_empty() && !query.tag_ids_any.iter().any(has_tag))
|| query.tag_ids_none.iter().any(has_tag)
{
return false;
}
if !query.states.is_empty()
&& !query
.states
.iter()
.any(|state| server_api::BrowserTicketState::from(*state) == summary.state)
{
return false;
}
if query
@@ -2300,6 +2335,7 @@ fn ticket_query_item(
title: summary.title,
state: summary.state,
readiness: detail.readiness.clone(),
tags: detail.tags.clone(),
priority: summary.priority,
created_at: detail.created_at.clone(),
updated_at: summary.updated_at,
@@ -2659,6 +2695,7 @@ fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> Result<TicketSummary>
kind: ticket.meta.kind.clone(),
priority: ticket.meta.priority.clone(),
labels: ticket.meta.labels.clone(),
tags: ticket.tags.clone(),
readiness: ticket.meta.readiness.clone(),
workflow_state: ticket.meta.workflow_state,
workflow_state_explicit: ticket.meta.workflow_state_explicit,
@@ -2690,6 +2727,7 @@ fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result<TicketS
state: item.summary.workflow_state.into(),
priority: item.summary.priority,
updated_at: item.summary.updated_at,
tags: item.summary.tags,
queued_by: item.summary.queued_by,
queued_at: item.summary.queued_at,
workspace_action_priority: workspace_action_priority_name(projection.priority).to_string(),
@@ -3497,7 +3535,7 @@ VALUES ('workspace-test', 'ticket', 4);
let ticket_query = authority
.query_tickets(TicketQueryRequest {
query: Some("Deep Ticket marker".to_string()),
states: vec![server_api::BrowserTicketState::Open],
states: vec![ticket::TicketState::Open],
linked_objective_id: Some("00000000001J3".to_string()),
attention: vec![server_api::TicketAttention::MissingCommit],
limit: Some(1),
@@ -42,6 +42,16 @@ fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() {
let fresh = SqliteWorkspaceStore::in_memory().unwrap();
let mut mismatches = Vec::new();
for table in [
"workspace_tags",
"typed_ticket_tags",
"workspace_tag_operations",
"typed_tickets",
"typed_ticket_targets",
"typed_ticket_case_metadata",
"typed_ticket_case_history",
"typed_ticket_legacy_workflow_snapshot",
"ticket_current_worker_assignments",
"ticket_assignment_work_releases",
"artifacts",
"workspace_config_trees",
"workspace_config_tree_history",
+135 -2
View File
@@ -6575,6 +6575,15 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
.merge(server_api::server_api_axum::ticket_case_update(
service.clone(),
))
.merge(server_api::server_api_axum::workspace_ticket_tag_list(
service.clone(),
))
.merge(server_api::server_api_axum::workspace_ticket_tag_mutate(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_tags_set(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_relation_view(
service.clone(),
))
@@ -10297,6 +10306,7 @@ impl server_api::ServerApi for ServerApiContractService {
kind: meta.kind,
priority: meta.priority,
labels: meta.labels,
tags: updated.tags,
readiness: meta.readiness,
workflow_state: meta.workflow_state,
workflow_state_explicit: meta.workflow_state_explicit,
@@ -10306,6 +10316,81 @@ impl server_api::ServerApi for ServerApiContractService {
}))
}
async fn workspace_ticket_tag_list(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
request: server_api::WorkspaceTicketTagListRequest,
) -> std::result::Result<server_api::WorkspaceTicketTagList, server_api::RepositoryApiError>
{
let result = execute_ticket_rest_operation_with_context(
self.workspace_api()?,
&workspace_id,
contract_request_headers(&context)?,
Some(&context),
TicketBackendOperation::ListWorkspaceTags { request: request.0 },
)
.await
.map_err(ApiError::into_repository_api_error)?;
let Json(value) = ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::WorkspaceTags(value) => Some(value),
_ => None,
})
.map_err(ApiError::into_repository_api_error)?;
Ok(server_api::WorkspaceTicketTagList(value))
}
async fn workspace_ticket_tag_mutate(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
request: server_api::WorkspaceTicketTagMutationRequest,
) -> std::result::Result<server_api::WorkspaceTicketTag, server_api::RepositoryApiError> {
let result = execute_ticket_rest_operation_with_context(
self.workspace_api()?,
&workspace_id,
contract_request_headers(&context)?,
Some(&context),
TicketBackendOperation::MutateWorkspaceTag { request: request.0 },
)
.await
.map_err(ApiError::into_repository_api_error)?;
let Json(value) = ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::WorkspaceTag(value) => Some(value),
_ => None,
})
.map_err(ApiError::into_repository_api_error)?;
Ok(server_api::WorkspaceTicketTag(value))
}
async fn ticket_tags_set(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
id: String,
request: server_api::TicketTagsSetRequest,
) -> std::result::Result<server_api::WorkspaceTicketTagList, server_api::RepositoryApiError>
{
let result = execute_ticket_rest_operation_with_context(
self.workspace_api()?,
&workspace_id,
contract_request_headers(&context)?,
Some(&context),
TicketBackendOperation::SetTicketTags {
id: TicketIdOrSlug::Query(id),
request: request.0,
},
)
.await
.map_err(ApiError::into_repository_api_error)?;
let Json(value) = ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::WorkspaceTags(value) => Some(value),
_ => None,
})
.map_err(ApiError::into_repository_api_error)?;
Ok(server_api::WorkspaceTicketTagList(value))
}
async fn ticket_relation_view(
&self,
context: server_api::ServerRequestContext,
@@ -14351,6 +14436,16 @@ async fn execute_ticket_rest_operation(
api: &WorkspaceApi,
workspace_id: &str,
headers: HeaderMap,
operation: TicketBackendOperation,
) -> ApiResult<TicketBackendOperationResult> {
execute_ticket_rest_operation_with_context(api, workspace_id, headers, None, operation).await
}
async fn execute_ticket_rest_operation_with_context(
api: &WorkspaceApi,
workspace_id: &str,
headers: HeaderMap,
context: Option<&server_api::ServerRequestContext>,
mut operation: TicketBackendOperation,
) -> ApiResult<TicketBackendOperationResult> {
validate_workspace_scope(api, workspace_id)?;
@@ -14379,7 +14474,31 @@ async fn execute_ticket_rest_operation(
// Human clients are authorized by the Workspace route boundary. Runtime-forwarded Worker
// calls carry source headers; when either header is present, the complete pair is required
// and authenticated before source attribution is attached.
let source = optional_worker_mutation_source(api, workspace_id, &headers)?;
let hinted_source = optional_worker_mutation_source(api, workspace_id, &headers)?;
// The signed Workspace request carries verified identity in the middleware
// extension, independently of optional legacy source hints. Classification
// receipts and audit must not collapse distinct Workers into a human/no-source
// identity just because those redundant headers are absent.
let verified_source = context
.and_then(|context| context.runtime_source.as_ref())
.map(|source| {
let worker_id = source.worker_id.as_ref().ok_or_else(|| {
Error::WorkspacePermissionDenied(
"Classification requests require a verified Runtime Worker identity".into(),
)
})?;
Ok::<_, Error>(RuntimeWorkerRef::new(&source.runtime_id, worker_id))
})
.transpose()?;
if let (Some(verified), Some(hinted)) = (&verified_source, &hinted_source)
&& verified != hinted
{
return Err(Error::WorkspacePermissionDenied(
"Classification source hints do not match the verified Runtime Worker".into(),
)
.into());
}
let source = verified_source.or(hinted_source);
validate_ticket_repository_operation(api, &operation)?;
let before = target.as_ref().and_then(|id| backend.show(id.clone()).ok());
if let Some(source) = source.as_ref()
@@ -14408,6 +14527,12 @@ async fn execute_ticket_rest_operation(
.map(|ticket| ticket.meta.workflow_state.as_str().to_string())
.unwrap_or_else(|| ticket_operation_initial_state(&operation));
let mut event_attributes = BTreeMap::new();
if source.is_none()
&& let Some(actor) = context.and_then(|context| context.actor.as_ref())
{
event_attributes.insert("source_account_id".into(), actor.account_id.clone());
event_attributes.insert("source_user_id".into(), actor.user_id.clone());
}
if matches!(operation, TicketBackendOperation::QueueReady { .. }) {
let ticket = before.as_ref().ok_or_else(|| {
Error::TicketAssignmentConflict(
@@ -14495,7 +14620,8 @@ async fn execute_ticket_rest_operation(
None,
),
};
if is_mutation {
// Classification has no routing or execution side effects.
if is_mutation && !matches!(operation_kind, "mutate_workspace_tag" | "set_ticket_tags") {
if let TicketBackendOperationResult::QueueOutcome(outcome) = &result {
for ticket_id in &outcome.queued_tickets {
notify_ticket_recipients(
@@ -16558,6 +16684,7 @@ fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketI
| TicketBackendOperation::AddIntakeSummary { id, .. }
| TicketBackendOperation::MarkReady { id, .. }
| TicketBackendOperation::QueueReady { id, .. }
| TicketBackendOperation::SetTicketTags { id, .. }
| TicketBackendOperation::AddTicketRelation { id, .. }
| TicketBackendOperation::RemoveTicketRelation { id, .. }
| TicketBackendOperation::AddOrchestrationPlanRecord { id, .. } => Some(id),
@@ -16621,6 +16748,8 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati
TicketBackendOperation::AddIntakeSummary { .. } => "add_intake_summary",
TicketBackendOperation::MarkReady { .. } => "mark_ready",
TicketBackendOperation::QueueReady { .. } => "queue_ready",
TicketBackendOperation::MutateWorkspaceTag { .. } => "mutate_workspace_tag",
TicketBackendOperation::SetTicketTags { .. } => "set_ticket_tags",
TicketBackendOperation::AddTicketRelation { .. } => "add_relation",
TicketBackendOperation::RemoveTicketRelation { .. } => "remove_relation",
TicketBackendOperation::AddOrchestrationPlanRecord { .. } => "add_plan_record",
@@ -32252,6 +32381,10 @@ impl IntoResponse for ApiError {
#[cfg(test)]
mod tests {
mod ticket_tags_tests {
include!("server_ticket_tags_tests.rs");
}
mod api_logging_tests;
mod auth_logging_tests;
mod drive_api_tests;
@@ -0,0 +1,971 @@
use super::*;
fn tag_test_case_update(
backend: &ticket::SqliteTicketBackend,
id: &str,
key: &str,
action: ticket::TicketCaseAction,
) -> ticket::Ticket {
let current = backend.show(id.into()).unwrap();
backend
.update_case(
id,
ticket::TicketCaseUpdate {
expected_content_digest: ticket::ticket_content_digest(&current),
expected_state: current.meta.workflow_state,
expected_closure_operation_key: current.closure_operation_key,
operation_key: key.into(),
action,
explanation: "Independent case judgment".into(),
references: vec![],
author: Some("case-fixture".into()),
},
)
.unwrap()
}
// Exercise the generated tag routes behind real Runtime authentication. Each
// operation retry gets a fresh proof; an HTTP proof itself is single-use.
async fn runtime_tag_response(
app: Router,
request: Request<Body>,
expected: StatusCode,
) -> serde_json::Value {
let response = app.oneshot(request).await.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(status, expected, "{}", String::from_utf8_lossy(&body));
if body.is_empty() {
serde_json::Value::Null
} else {
serde_json::from_slice(&body).unwrap_or_else(|error| {
panic!(
"invalid JSON response: {error}: {}",
String::from_utf8_lossy(&body)
)
})
}
}
async fn runtime_tag_json(
app: Router,
identity: &RuntimeIdentityMaterial,
worker_id: &str,
method: &str,
path: &str,
input: serde_json::Value,
expected: StatusCode,
) -> serde_json::Value {
runtime_tag_response(
app,
runtime_source_request(
identity,
Some(worker_id),
method,
path,
serde_json::to_vec(&input).unwrap(),
),
expected,
)
.await
}
fn tag_execution_authority_snapshot(api: &WorkspaceApi) -> Vec<Vec<Vec<rusqlite::types::Value>>> {
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
[
"worker_registry",
"worker_control_grants",
"ticket_current_worker_assignments",
"ticket_assignment_operations",
"backend_jobs",
]
.into_iter()
.map(|table| {
let mut statement = conn
.prepare(&format!(
"SELECT * FROM {table} WHERE workspace_id=?1 ORDER BY rowid"
))
.unwrap();
let columns = statement.column_count();
statement
.query_map([TEST_WORKSPACE_ID], |row| {
(0..columns)
.map(|column| row.get(column))
.collect::<rusqlite::Result<Vec<_>>>()
})
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
})
.collect()
}
#[tokio::test]
async fn runtime_tag_mutations_replay_original_receipts_and_preserve_execution_authority() {
let dir = tempfile::tempdir().unwrap();
let (mut api, execution) = test_api_with_recording_backend(dir.path()).await;
let identity = RuntimeIdentityMaterial::generate("runtime-tags").unwrap();
configure_runtime_request_auth(&mut api, &identity, "runtime-tags");
let source = RuntimeWorkerRef::new("runtime-tags", "classifier");
let recipient = RuntimeWorkerRef::new("runtime-tags", "recipient");
seed_worker_source_member(&api, &source.runtime_id, &source.worker_id);
seed_worker_source_member(&api, &recipient.runtime_id, &recipient.worker_id);
seed_worker_control_grant(&api, &source, &recipient, "existing-control");
let backend = browser_ticket_backend(&api).unwrap();
let reference = backend
.create(ticket::NewTicket::new("Classification is not a decision"))
.unwrap();
// Routing is an immutable receipt on an open case, not a six-state value.
let Json(queue) = scoped_queue_ticket_record(
State(api.clone()),
AxumPath((TEST_WORKSPACE_ID.into(), reference.id.clone())),
HeaderMap::new(),
)
.await
.unwrap();
assert_eq!(queue.queued_tickets, vec![reference.id.clone()]);
assert_eq!(
backend
.show(reference.id.clone().into())
.unwrap()
.meta
.workflow_state,
ticket::TicketState::Open
);
api.store
.set_current_ticket_role_assignment(
&TicketRoleAssignmentRecord {
workspace_id: TEST_WORKSPACE_ID.into(),
ticket_id: reference.id.clone(),
assignment_id: "existing-assignment".into(),
role: TicketAssignmentRole::Worker,
principal: TicketAssignmentPrincipal::Worker {
runtime_id: recipient.runtime_id.clone(),
worker_id: recipient.worker_id.clone(),
},
assigned_by: "test".into(),
assigned_at: TEST_CREATED_AT.into(),
},
None,
"existing-assignment-event",
"existing-assignment-operation",
false,
)
.unwrap();
let authority_before = tag_execution_authority_snapshot(&api);
let ticket_before = backend.show(reference.id.clone().into()).unwrap();
let app = build_router(api.clone());
let tags_path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags");
let membership_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/tags", reference.id);
let create =
json!({"operation_key":"runtime-create", "action":{"kind":"create", "name":" ready "}});
let tag = runtime_tag_json(
app.clone(),
&identity,
&source.worker_id,
"POST",
&tags_path,
create.clone(),
StatusCode::OK,
)
.await;
assert_eq!(tag["name"], "ready");
assert_eq!(tag["workspace_id"], TEST_WORKSPACE_ID);
let attach =
json!({"operation_key":"runtime-attach", "expected_tag_ids":[], "tag_ids":[tag["id"]]});
let attached = runtime_tag_json(
app.clone(),
&identity,
&source.worker_id,
"PUT",
&membership_path,
attach.clone(),
StatusCode::OK,
)
.await;
assert_eq!(attached, json!([tag.clone()]));
assert_eq!(
runtime_tag_json(
app.clone(),
&identity,
&source.worker_id,
"PUT",
&membership_path,
attach.clone(),
StatusCode::OK
)
.await,
attached
);
let rename = json!({"operation_key":"runtime-rename", "action":{"kind":"rename", "tag_id":tag["id"], "expected_last_operation_key":"runtime-create", "name":"Ready now"}});
let renamed = runtime_tag_json(
app.clone(),
&identity,
&source.worker_id,
"POST",
&tags_path,
rename.clone(),
StatusCode::OK,
)
.await;
assert_eq!(renamed["id"], tag["id"]);
assert_eq!(renamed["name"], "Ready now");
assert_eq!(
serde_json::to_value(&backend.show(reference.id.clone().into()).unwrap().tags).unwrap(),
json!([renamed.clone()])
);
let delete = json!({"operation_key":"runtime-delete", "action":{"kind":"delete", "tag_id":tag["id"], "expected_last_operation_key":"runtime-rename"}});
let deleted = runtime_tag_json(
app.clone(),
&identity,
&source.worker_id,
"POST",
&tags_path,
delete.clone(),
StatusCode::OK,
)
.await;
assert_eq!(deleted["deleted"], true);
assert!(
backend
.show(reference.id.clone().into())
.unwrap()
.tags
.is_empty()
);
// Receipts return their original snapshots, even after rename/delete changed
// live state. Retries must not recreate membership or duplicate audit events.
for (input, expected) in [(create, tag), (rename, renamed), (delete, deleted)] {
assert_eq!(
runtime_tag_json(
app.clone(),
&identity,
&source.worker_id,
"POST",
&tags_path,
input,
StatusCode::OK
)
.await,
expected
);
}
assert_eq!(
runtime_tag_json(
app.clone(),
&identity,
&source.worker_id,
"PUT",
&membership_path,
attach,
StatusCode::OK
)
.await,
attached
);
runtime_tag_json(app.clone(), &identity, &source.worker_id, "PUT", &membership_path,
json!({"operation_key":"runtime-stale-set", "expected_tag_ids":[attached[0]["id"]], "tag_ids":[]}), StatusCode::CONFLICT).await;
let empty = runtime_tag_json(
app,
&identity,
&source.worker_id,
"PUT",
&membership_path,
json!({"operation_key":"runtime-empty-set", "expected_tag_ids":[], "tag_ids":[]}),
StatusCode::OK,
)
.await;
assert_eq!(empty, json!([]));
let ticket_after = backend.show(reference.id.into()).unwrap();
assert_eq!(
ticket::ticket_content_digest(&ticket_before),
ticket::ticket_content_digest(&ticket_after)
);
assert_eq!(
ticket_before.meta.workflow_state,
ticket_after.meta.workflow_state
);
assert_eq!(ticket_before.current_closure, ticket_after.current_closure);
assert_eq!(ticket_before.case_history, ticket_after.case_history);
assert_eq!(ticket_before.meta.queued_by, ticket_after.meta.queued_by);
assert_eq!(ticket_before.meta.queued_at, ticket_after.meta.queued_at);
assert_eq!(
ticket_before
.events
.iter()
.filter(|event| event.kind != ticket::TicketEventKind::TagsChanged)
.collect::<Vec<_>>(),
ticket_after
.events
.iter()
.filter(|event| event.kind != ticket::TicketEventKind::TagsChanged)
.collect::<Vec<_>>()
);
assert!(ticket_after.tags.is_empty());
assert_eq!(tag_execution_authority_snapshot(&api), authority_before);
assert!(
execution.protocol_methods().is_empty(),
"classification dispatched execution/routing"
);
let tag_events: Vec<_> = ticket_after
.events
.iter()
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
.collect();
assert_eq!(
tag_events.len(),
2,
"attach/delete retries duplicated membership audit"
);
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
let mut statement = conn.prepare("SELECT operation_key,source_context_json FROM workspace_tag_operations WHERE workspace_id=?1 ORDER BY operation_key").unwrap();
let receipts = statement
.query_map([TEST_WORKSPACE_ID], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap();
assert_eq!(receipts.len(), 5);
for (key, context) in receipts {
let context: serde_json::Value = serde_json::from_str(&context).unwrap();
assert_eq!(
context["source_runtime_id"], source.runtime_id,
"receipt {key}: {context}"
);
assert_eq!(
context["source_worker_id"], source.worker_id,
"receipt {key}: {context}"
);
assert!(
context.get("source_account_id").is_none(),
"signed source became a human actor: {context}"
);
}
for event in tag_events {
assert_eq!(
event.attributes.get("source_runtime_id"),
Some(&source.runtime_id)
);
assert_eq!(
event.attributes.get("source_worker_id"),
Some(&source.worker_id)
);
}
}
#[tokio::test]
async fn runtime_tag_receipt_replay_is_bound_to_verified_worker_not_transport_proof() {
let dir = tempfile::tempdir().unwrap();
let mut api = test_api(dir.path()).await;
let identity = RuntimeIdentityMaterial::generate("runtime-tags").unwrap();
configure_runtime_request_auth(&mut api, &identity, "runtime-tags");
for worker in ["classifier", "other"] {
seed_worker_source_member(&api, "runtime-tags", worker);
}
let app = build_router(api.clone());
let path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags");
let input =
json!({"operation_key":"worker-receipt", "action":{"kind":"create", "name":"ready"}});
let request = runtime_source_request(
&identity,
Some("classifier"),
"POST",
&path,
serde_json::to_vec(&input).unwrap(),
);
let proof =
request.headers()[worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER].clone();
let tag = runtime_tag_response(app.clone(), request, StatusCode::OK).await;
runtime_tag_response(
app.clone(),
Request::builder()
.method("POST")
.uri(&path)
.header(CONTENT_TYPE, "application/json")
.header(
worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
proof,
)
.body(Body::from(serde_json::to_vec(&input).unwrap()))
.unwrap(),
StatusCode::UNAUTHORIZED,
)
.await;
assert_eq!(
runtime_tag_json(
app.clone(),
&identity,
"classifier",
"POST",
&path,
input.clone(),
StatusCode::OK
)
.await,
tag
);
runtime_tag_json(
app.clone(),
&identity,
"classifier",
"POST",
&path,
json!({"operation_key":"worker-receipt", "action":{"kind":"create", "name":"changed"}}),
StatusCode::CONFLICT,
)
.await;
runtime_tag_json(
app,
&identity,
"other",
"POST",
&path,
input,
StatusCode::CONFLICT,
)
.await;
assert_eq!(
browser_ticket_backend(&api)
.unwrap()
.list_workspace_tags(ticket::TagListRequest::default())
.unwrap()
.len(),
1
);
}
#[tokio::test]
async fn runtime_tag_mutations_reject_invalid_proofs_and_changed_binding_authority() {
let dir = tempfile::tempdir().unwrap();
let (mut api, execution) = test_api_with_recording_backend(dir.path()).await;
let identity = RuntimeIdentityMaterial::generate("runtime-tags").unwrap();
configure_runtime_request_auth(&mut api, &identity, "runtime-tags");
seed_worker_source_member(&api, "runtime-tags", "classifier");
let backend = browser_ticket_backend(&api).unwrap();
let ticket = backend
.create(ticket::NewTicket::new("Rejected classification"))
.unwrap();
let ticket_before = backend.show(ticket.id.clone().into()).unwrap();
let authority_before = tag_execution_authority_snapshot(&api);
let app = build_router(api.clone());
let tags_path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags");
let membership_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/tags", ticket.id);
let attacker = RuntimeIdentityMaterial::generate("runtime-tags").unwrap();
// Both mutation routes must reject before writing tags, memberships, receipts
// or audit events. There is no persisted verification status in this fixture.
for (method, path, input) in [
(
"POST",
tags_path.as_str(),
json!({"operation_key":"denied-create", "action":{"kind":"create", "name":"denied"}}),
),
(
"PUT",
membership_path.as_str(),
json!({"operation_key":"denied-set", "expected_tag_ids":[], "tag_ids":[]}),
),
] {
let body = serde_json::to_vec(&input).unwrap();
let unsigned = Request::builder()
.method(method)
.uri(path)
.header(CONTENT_TYPE, "application/json")
.header("x-yoi-runtime-id", "runtime-tags")
.header("x-yoi-worker-id", "classifier")
.body(Body::from(body.clone()))
.unwrap();
runtime_tag_response(app.clone(), unsigned, StatusCode::UNAUTHORIZED).await;
runtime_tag_json(
app.clone(),
&attacker,
"classifier",
method,
path,
input.clone(),
StatusCode::UNAUTHORIZED,
)
.await;
runtime_tag_json(
app.clone(),
&identity,
"not-a-member",
method,
path,
input.clone(),
StatusCode::UNAUTHORIZED,
)
.await;
let proof = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&identity)
.issue(
"server-test",
"another-workspace",
Some("classifier"),
worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION,
method,
path,
&body,
i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap(),
30,
)
.unwrap();
runtime_tag_response(
app.clone(),
Request::builder()
.method(method)
.uri(path)
.header(CONTENT_TYPE, "application/json")
.header(
worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
proof,
)
.body(Body::from(body.clone()))
.unwrap(),
StatusCode::UNAUTHORIZED,
)
.await;
let mut tampered =
runtime_source_request(&identity, Some("classifier"), method, path, body);
*tampered.body_mut() = Body::from(format!("{} ", input));
runtime_tag_response(app.clone(), tampered, StatusCode::UNAUTHORIZED).await;
}
let store = SqliteWorkspaceStore::open(&api.config.database_path).unwrap();
let original = api
.store
.get_workspace_runtime_binding(TEST_WORKSPACE_ID, "runtime-tags")
.await
.unwrap()
.unwrap();
let mut replacement = original.clone();
replacement.public_key = attacker.public_key.clone();
replacement.public_key_fingerprint = String::new();
store
.upsert_workspace_runtime_binding(replacement, true)
.unwrap();
for (method, path, input) in [
(
"POST",
tags_path.as_str(),
json!({"operation_key":"stale-key-create", "action":{"kind":"create", "name":"stale"}}),
),
(
"PUT",
membership_path.as_str(),
json!({"operation_key":"stale-key-set", "expected_tag_ids":[], "tag_ids":[]}),
),
] {
runtime_tag_json(
app.clone(),
&identity,
"classifier",
method,
path,
input,
StatusCode::UNAUTHORIZED,
)
.await;
}
store
.upsert_workspace_runtime_binding(original, true)
.unwrap();
store
.revoke_workspace_runtime_binding(TEST_WORKSPACE_ID, "runtime-tags", TEST_CREATED_AT)
.unwrap();
for (method, path, input) in [
(
"POST",
tags_path.as_str(),
json!({"operation_key":"revoked-create", "action":{"kind":"create", "name":"revoked"}}),
),
(
"PUT",
membership_path.as_str(),
json!({"operation_key":"revoked-set", "expected_tag_ids":[], "tag_ids":[]}),
),
] {
runtime_tag_json(
app.clone(),
&identity,
"classifier",
method,
path,
input,
StatusCode::UNAUTHORIZED,
)
.await;
}
assert!(
backend
.list_workspace_tags(ticket::TagListRequest::default())
.unwrap()
.is_empty()
);
assert_eq!(
serde_json::to_value(backend.show(ticket.id.into()).unwrap()).unwrap(),
serde_json::to_value(ticket_before).unwrap()
);
assert_eq!(tag_execution_authority_snapshot(&api), authority_before);
assert!(execution.protocol_methods().is_empty());
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
let receipts: i64 = conn
.query_row(
"SELECT COUNT(*) FROM workspace_tag_operations WHERE workspace_id=?1",
[TEST_WORKSPACE_ID],
|row| row.get(0),
)
.unwrap();
assert_eq!(receipts, 0);
}
#[tokio::test]
async fn ticket_tags_round_trip_filter_and_preserve_execution_and_decision_projections() {
let dir = tempfile::tempdir().unwrap();
let (api, execution) = test_api_with_recording_backend(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let reference = backend
.create(ticket::NewTicket::new("Classification only"))
.unwrap();
let other = backend
.create(ticket::NewTicket::new("Other ticket"))
.unwrap();
let reasoned = tag_test_case_update(
&backend,
&reference.id,
"reasoned-close",
ticket::TicketCaseAction::Close {
reason: ticket::TicketClosureReason::NotPlanned,
},
);
let app = build_inner_router(api.clone());
let tags_path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags");
let ticket_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id);
let before = get_json(app.clone(), &ticket_path).await;
let ready = post_json(
app.clone(),
&tags_path,
json!({
"operation_key":"create-ready", "action":{"kind":"create", "name":" ready "}
}),
)
.await;
let bug = post_json(
app.clone(),
&tags_path,
json!({
"operation_key":"create-bug", "action":{"kind":"create", "name":"closed"}
}),
)
.await;
let ready_id = ready["id"].as_str().unwrap();
let bug_id = bug["id"].as_str().unwrap();
let assignment =
json!({"operation_key":"classify", "expected_tag_ids":[], "tag_ids":[ready_id,bug_id]});
let attached = request_json(
app.clone(),
"PUT",
&format!("{ticket_path}/tags"),
Some(assignment.clone()),
StatusCode::OK,
)
.await;
assert_eq!(attached.as_array().unwrap().len(), 2);
let replay = request_json(
app.clone(),
"PUT",
&format!("{ticket_path}/tags"),
Some(assignment),
StatusCode::OK,
)
.await;
assert_eq!(attached, replay);
let after = get_json(app.clone(), &ticket_path).await;
for field in [
"state",
"current_closure",
"closure_operation_key",
"legacy_handoff",
"content_digest",
"current_worker",
"assignments",
"action_eligibility",
"targets",
"evidence",
"merge_requests",
"queued_by",
"queued_at",
] {
assert_eq!(
before[field], after[field],
"classification changed {field}"
);
}
assert_eq!(after["tags"].as_array().unwrap().len(), 2);
assert!(execution.protocol_methods().is_empty());
let list = get_json(app.clone(), &format!("/api/w/{TEST_WORKSPACE_ID}/tickets")).await;
let item = list["items"]
.as_array()
.unwrap()
.iter()
.find(|item| item["id"] == reference.id)
.unwrap();
assert_eq!(item["tags"].as_array().unwrap().len(), 2);
let query_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query");
for (filter, count) in [
(json!({"tag_ids":[ready_id,bug_id]}), 1),
(json!({"tag_ids_any":[ready_id,"absent"]}), 1),
(json!({"tag_ids_none":[ready_id]}), 1),
(json!({"tag_ids":[ready_id,"absent"]}), 0),
] {
let result = post_json(app.clone(), &query_path, filter.clone()).await;
assert_eq!(
result["items"].as_array().unwrap().len(),
count,
"filter {filter}"
);
if count == 1 && filter.get("tag_ids_none").is_none() {
assert_eq!(result["items"][0]["tags"].as_array().unwrap().len(), 2);
} else if filter.get("tag_ids_none").is_some() {
assert_eq!(result["items"][0]["id"], other.id);
}
}
let renamed = post_json(
app.clone(),
&tags_path,
json!({
"operation_key":"rename", "action":{"kind":"rename", "tag_id":bug_id,
"expected_last_operation_key":"create-bug", "name":"Defect"}
}),
)
.await;
assert_eq!(renamed["id"], bug["id"]);
let shown = get_json(app.clone(), &ticket_path).await;
assert!(
shown["tags"]
.as_array()
.unwrap()
.iter()
.any(|tag| tag["name"] == "Defect")
);
request_json(
app.clone(),
"POST",
&tags_path,
Some(json!({
"operation_key":"stale-rename", "action":{"kind":"rename", "tag_id":bug_id,
"expected_last_operation_key":"create-bug", "name":"Wrong"}
})),
StatusCode::CONFLICT,
)
.await;
post_json(
app.clone(),
&tags_path,
json!({
"operation_key":"delete", "action":{"kind":"delete", "tag_id":bug_id,
"expected_last_operation_key":"rename"}
}),
)
.await;
let deleted = get_json(app.clone(), &ticket_path).await;
assert_eq!(deleted["tags"].as_array().unwrap().len(), 1);
request_json(
app.clone(),
"PUT",
&format!("{ticket_path}/tags"),
Some(json!({
"operation_key":"stale-set", "expected_tag_ids":[ready_id,bug_id], "tag_ids":[]
})),
StatusCode::CONFLICT,
)
.await;
let untagged = request_json(
app.clone(),
"PUT",
&format!("{ticket_path}/tags"),
Some(json!({
"operation_key":"detach", "expected_tag_ids":[ready_id], "tag_ids":[]
})),
StatusCode::OK,
)
.await;
assert_eq!(untagged, json!([]));
let final_detail = get_json(app, &ticket_path).await;
assert_eq!(final_detail["state"], before["state"]);
assert_eq!(final_detail["content_digest"], before["content_digest"]);
let classified = backend.show(reference.id.clone().into()).unwrap();
assert_eq!(classified.current_closure, reasoned.current_closure);
assert_eq!(classified.case_history, reasoned.case_history);
let tag_history: Vec<_> = classified
.events
.iter()
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
.cloned()
.collect();
let corrected = tag_test_case_update(
&backend,
&reference.id,
"correct-close",
ticket::TicketCaseAction::CorrectClosure {
reason: ticket::TicketClosureReason::Completed,
},
);
assert_eq!(corrected.tags, classified.tags);
assert_eq!(corrected.case_history[0], reasoned.case_history[0]);
let reopened = tag_test_case_update(
&backend,
&reference.id,
"reopen-case",
ticket::TicketCaseAction::Reopen,
);
assert_eq!(reopened.tags, classified.tags);
assert_eq!(
reopened
.events
.iter()
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
.cloned()
.collect::<Vec<_>>(),
tag_history
);
assert!(execution.protocol_methods().is_empty());
}
#[tokio::test]
async fn ticket_tag_routes_require_workspace_authority_and_reject_forged_worker_source() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let owner_token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
let collaborator_token = seed_test_api_token(api.store.as_ref(), "tags-collaborator");
let app = build_router(api.clone());
let path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags");
let input = json!({"operation_key":"authorized", "action":{"kind":"create", "name":"ready"}});
request_json(
app.clone(),
"POST",
&path,
Some(input.clone()),
StatusCode::UNAUTHORIZED,
)
.await;
let tag = request_json_authenticated(
app.clone(),
"POST",
&path,
Some(input),
&owner_token,
StatusCode::OK,
)
.await;
assert_eq!(tag["workspace_id"], TEST_WORKSPACE_ID);
// Classification follows shared Ticket Workspace write authority, not the
// owner-only repository/secrets-management policy. A distinct authenticated
// actor may manage tags but cannot replay another actor's receipt.
request_json_authenticated(
app.clone(),
"POST",
&path,
Some(json!({"operation_key":"authorized", "action":{"kind":"create", "name":"ready"}})),
&collaborator_token,
StatusCode::CONFLICT,
)
.await;
request_json_authenticated(
app.clone(),
"POST",
"/api/w/other-workspace/ticket-tags",
Some(json!({"operation_key":"foreign", "action":{"kind":"create", "name":"foreign"}})),
&collaborator_token,
StatusCode::NOT_FOUND,
)
.await;
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(&path)
.header(
axum::http::header::AUTHORIZATION,
format!("Bearer {owner_token}"),
)
.header("x-yoi-runtime-id", "untrusted-runtime")
.header("x-yoi-worker-id", "untrusted-worker")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
json!({"operation_key":"forged", "action":{"kind":"create", "name":"forged"}})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"{}",
String::from_utf8_lossy(&body)
);
let tags = browser_ticket_backend(&api)
.unwrap()
.list_workspace_tags(ticket::TagListRequest::default())
.unwrap();
assert_eq!(tags.len(), 1);
}
#[tokio::test]
async fn tag_query_filters_precede_bounded_candidate_scan_and_bind_cursors() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
for i in 0..5 {
backend
.create(ticket::NewTicket::new(format!("a unrelated {i}")))
.unwrap();
}
let tag = backend
.mutate_workspace_tag(ticket::WorkspaceTagMutation {
operation_key: "create".into(),
action: ticket::WorkspaceTagAction::Create { name: "bug".into() },
})
.unwrap();
for i in 0..3 {
let id = backend
.create(ticket::NewTicket::new(format!("z tagged {i}")))
.unwrap()
.id;
backend
.set_ticket_tags(
id.into(),
ticket::TicketTagSetRequest {
expected_tag_ids: vec![],
tag_ids: vec![tag.id.clone()],
operation_key: format!("set-{i}"),
},
)
.unwrap();
}
let app = build_inner_router(api);
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query");
let mut input = json!({"tag_ids":[tag.id], "sort":"title", "limit":1});
for i in 0..3 {
let page = post_json(app.clone(), &path, input.clone()).await;
assert_eq!(page["items"].as_array().unwrap().len(), 1);
assert_eq!(page["items"][0]["title"], format!("z tagged {i}"));
if i < 2 {
let cursor = page["page"]["next_cursor"].clone();
request_json(
app.clone(),
"POST",
&path,
Some(json!({"tag_ids_any":[tag.id], "sort":"title", "limit":1, "cursor":cursor})),
StatusCode::NOT_FOUND,
)
.await;
input["cursor"] = cursor;
} else {
assert_eq!(page["page"]["has_more"], false);
}
}
}
+50 -5
View File
@@ -13059,8 +13059,13 @@ fn apply_ticket_targets_and_workdir_capabilities_schema(conn: &Connection) -> Re
TICKET_SCHEMA_BASELINE_NAME
],
)?;
// This retained step composes the historical Ticket schema 7.
// Current-domain verification follows the shared case migration at schema 91.
// Verify the retained six-state v7 boundary here; the shared domain
// migrator installs classification and cases only after Workspace auth90.
ticket::verify_pre_classification_ticket_schema(db).map_err(|error| {
Error::Store(format!(
"schema-66 Ticket schema verification failed: {error}"
))
})?;
let foreign_key_violations =
db.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
@@ -14547,6 +14552,9 @@ mod tests {
}
fn downgrade_schema_66_ticket_and_workdir_authority(conn: &Connection) -> Result<()> {
// A retained pre-target fixture must not carry today's classification
// tables while resetting the component migration marker to v6.
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_tags; DROP TABLE IF EXISTS workspace_tag_operations; DROP TABLE IF EXISTS workspace_tags;")?;
conn.execute_batch("DROP TABLE IF EXISTS worker_restore_intents;")?;
conn.execute_batch(
"DROP TABLE backend_job_deliveries;
@@ -15586,7 +15594,7 @@ mod tests {
migrate_ticket_targets_and_workdir_capabilities_v65_to_v66(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 66);
// The retained step is Ticket-7; current verification runs after schema 91.
ticket::verify_pre_classification_ticket_schema(&conn).unwrap();
assert!(!column_exists(&conn, "typed_tickets", "repository_id").unwrap());
assert!(!column_exists(&conn, "typed_tickets", "ref_selector").unwrap());
assert_eq!(
@@ -15899,7 +15907,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
fn create_frozen_schema_v85(conn: &Connection) -> Result<()> {
// Compose the frozen Ticket-7 baseline, not current Ticket case tables.
conn.execute_batch(
"DROP TABLE IF EXISTS typed_ticket_case_metadata;
"DROP TABLE IF EXISTS typed_ticket_tags;
DROP TABLE IF EXISTS workspace_tag_operations;
DROP TABLE IF EXISTS workspace_tags;
DROP TABLE IF EXISTS typed_ticket_case_metadata;
DROP TABLE IF EXISTS typed_ticket_case_history;
DROP TABLE IF EXISTS typed_ticket_legacy_workflow_snapshot;
DELETE FROM ticket_schema_migrations;
@@ -15984,11 +15995,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
"#,
)?;
conn.execute_batch(include_str!("assignment_work.sql"))?;
// Verification uses current schema 9 only after the real retained chain.
// Current composed Ticket verification follows the real retained chain.
Ok(())
}
include!("schema_equivalence_tests.rs");
include!("ticket_tag_upgrade_tests.rs");
include!("migration_entrypoint_tests.rs");
include!("legacy_evidence_column_tests.rs");
@@ -18954,7 +18966,18 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
VALUES('case-workspace','case-runtime','pending-worker','Pending','normal','1','1')", []).unwrap();
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,runtime_id,worker_id,created_at,request_fingerprint,claim_state)
VALUES('case-workspace','pending-claim','assign','pending-queue','worker','case-runtime','pending-worker','1','pending-fingerprint','pending')", []).unwrap();
// Compose real retained Ticket8 classification data across the full
// six-state × MR matrix, before either auth90 or case91 runs.
install_retained_ticket_tags8(&conn);
let retained_tags = seed_retained_classification(&conn, "case-workspace", "done-true");
conn.execute("INSERT OR IGNORE INTO typed_ticket_tags(workspace_id,ticket_id,tag_id)
SELECT workspace_id,ticket_id,?1 FROM typed_tickets WHERE workspace_id='case-workspace'",
[&retained_tags.active_ids[0]]).unwrap();
let unchanged = [
"workspace_tags",
"typed_ticket_tags",
"workspace_tag_operations",
"typed_ticket_labels",
"typed_ticket_events",
"typed_ticket_event_attributes",
"typed_ticket_event_references",
@@ -19049,6 +19072,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
for has_mr in [false, true] {
let id = format!("{state}-{has_mr}");
let ticket = backend.show(id.clone().into()).unwrap();
assert!(!ticket.tags.is_empty(), "matrix classification {id}");
assert_eq!(ticket.document.body.as_str(), "Original requirements");
let snapshot = ticket.legacy_workflow_snapshot.as_ref().unwrap();
assert_eq!(snapshot.workflow_state, state);
@@ -19082,6 +19106,19 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
assert_eq!(reopened.meta.workflow_state, ticket::TicketState::Open);
assert_eq!(reopened.case_history.len(), 2);
assert_eq!(reopened.case_history[0], ticket.case_history[0]);
assert_eq!(reopened.tags, ticket.tags);
assert_eq!(
reopened
.events
.iter()
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
.collect::<Vec<_>>(),
ticket
.events
.iter()
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
.collect::<Vec<_>>()
);
assert!(
store
.get_active_ticket_worker_assignment("case-workspace", &id)
@@ -19193,11 +19230,15 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
VALUES('case-workspace','terminal','terminal','Terminal','closed','task','P2','','done',1);
DROP VIEW ticket_active_worker_assignments;
CREATE TABLE ticket_active_worker_assignments(collision TEXT);").unwrap();
install_retained_ticket_tags8(&conn);
let _tags = seed_retained_classification(&conn, "case-workspace", "terminal");
let tags_before = classification_rows(&conn);
let auth_before = fixture_auth_rows(&conn);
let error = migrate_ticket_cases_v90_to_v91(&conn).unwrap_err();
assert!(error.to_string().contains("DROP TABLE"));
assert_eq!(current_schema_version(&conn).unwrap(), 90);
assert_eq!(fixture_auth_rows(&conn), auth_before);
assert_eq!(classification_rows(&conn), tags_before);
// The shared transaction completed, but Workspace did not advertise a
// composed schema. Its immutable evidence remains the retry authority.
let before = stored_fixture_rows(&conn, "typed_ticket_case_history");
@@ -19207,6 +19248,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
migrate_ticket_cases_v90_to_v91(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 91);
assert_eq!(fixture_auth_rows(&conn), auth_before);
assert_eq!(classification_rows(&conn), tags_before);
assert_eq!(
stored_fixture_rows(&conn, "typed_ticket_case_history"),
before
@@ -19349,6 +19391,9 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
let upgraded = SqliteWorkspaceStore::open(&path).unwrap();
let fresh = SqliteWorkspaceStore::in_memory().unwrap();
for object in [
"workspace_tags",
"typed_ticket_tags",
"workspace_tag_operations",
"typed_tickets",
"typed_ticket_targets",
"typed_ticket_case_metadata",
@@ -0,0 +1,550 @@
// Included by store::tests. Genuine retained six-state Workspace89/90 fixtures
// compose auth90, classification and cases; no lowered current-schema markers.
// Frozen classification schema from develop b1fe562e (Ticket component 8).
// Deliberate persisted compatibility input, independent of the latest baseline.
fn install_retained_ticket_tags8(conn: &Connection) {
ticket::verify_pre_classification_ticket_schema(conn).unwrap();
conn.execute_batch(r#"
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);
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));
UPDATE ticket_schema_migrations SET version=8;
"#).unwrap();
}
#[derive(Clone)]
struct RetainedClassification {
source: std::collections::BTreeMap<String, String>,
request: ticket::TicketTagSetRequest,
receipt: Vec<ticket::WorkspaceTag>,
active_ids: Vec<String>,
deleted_id: String,
}
// Seed actual v8 persisted boundaries rather than invoking a latest-only API on
// a legacy database. Full source context is audit, only authenticated identity
// belongs to the replay fingerprint. It is deliberately not the assigned Worker.
fn seed_retained_classification(
conn: &Connection,
workspace: &str,
id: &str,
) -> RetainedClassification {
let source = std::collections::BTreeMap::from([
("source_runtime_id".into(), "case-runtime".into()),
("source_worker_id".into(), "classifier".into()),
("assigned_runtime_id".into(), "case-runtime".into()),
("assigned_worker_id".into(), id.into()),
("source_actor_role".into(), "ticket_worker".into()),
(
"source_assignment_id".into(),
format!("old-assignment-{id}"),
),
]);
let identity = std::collections::BTreeMap::from([
("source_runtime_id", "case-runtime"),
("source_worker_id", "classifier"),
]);
let mut receipt = Vec::new();
for (suffix, name, deleted) in [
("ready", "ready", false),
("closed", "closed", false),
("old", "Bug", true),
("replacement", "Bug", false),
] {
let tag = ticket::WorkspaceTag {
workspace_id: workspace.into(),
id: format!("{id}-{suffix}"),
name: name.into(),
last_operation_key: format!("{id}-{suffix}-create"),
deleted: false,
};
let request = ticket::WorkspaceTagMutation {
operation_key: tag.last_operation_key.clone(),
action: ticket::WorkspaceTagAction::Create { name: name.into() },
};
conn.execute(
"INSERT INTO workspace_tags VALUES(?1,?2,?3,?4,?5,?6)",
params![
workspace,
tag.id,
name,
name.to_lowercase(),
if deleted {
format!("{id}-delete")
} else {
tag.last_operation_key.clone()
},
deleted
],
)
.unwrap();
let input =
serde_json::to_string(&("mutate_workspace_tag", "", &request, &identity)).unwrap();
conn.execute("INSERT INTO workspace_tag_operations VALUES(?1,?2,'create',?3,?4,?5,'null',?6,'original-time')",
params![workspace, request.operation_key, tag.id, input, serde_json::to_string(&source).unwrap(), serde_json::to_string(&tag).unwrap()]).unwrap();
if suffix != "replacement" {
receipt.push(tag);
}
}
receipt.sort_by(|a, b| a.id.cmp(&b.id));
let request = ticket::TicketTagSetRequest {
operation_key: format!("{id}-attach"),
expected_tag_ids: vec![],
tag_ids: receipt.iter().map(|tag| tag.id.clone()).collect(),
};
let selector: ticket::TicketIdOrSlug = id.into();
let input =
serde_json::to_string(&("set_ticket_tags", "", &(&selector, &request), &identity)).unwrap();
conn.execute("INSERT INTO workspace_tag_operations VALUES(?1,?2,'set_ticket_tags',?3,?4,?5,'[]',?6,'original-time')",
params![workspace, request.operation_key, id, input, serde_json::to_string(&source).unwrap(), serde_json::to_string(&receipt).unwrap()]).unwrap();
let active: Vec<_> = receipt
.iter()
.filter(|tag| !tag.id.ends_with("-old"))
.cloned()
.collect();
let old = receipt.iter().find(|tag| tag.id.ends_with("-old")).unwrap();
let mut deleted = old.clone();
deleted.deleted = true;
deleted.last_operation_key = format!("{id}-delete");
let delete_request = ticket::WorkspaceTagMutation {
operation_key: deleted.last_operation_key.clone(),
action: ticket::WorkspaceTagAction::Delete {
tag_id: old.id.clone(),
expected_last_operation_key: old.last_operation_key.clone(),
},
};
let input =
serde_json::to_string(&("mutate_workspace_tag", "", &delete_request, &identity)).unwrap();
conn.execute("INSERT INTO workspace_tag_operations VALUES(?1,?2,'delete',?3,?4,?5,?6,?7,'original-time')",
params![workspace, delete_request.operation_key, old.id, input, serde_json::to_string(&source).unwrap(), serde_json::to_string(old).unwrap(), serde_json::to_string(&deleted).unwrap()]).unwrap();
for tag in &active {
conn.execute(
"INSERT INTO typed_ticket_tags VALUES(?1,?2,?3)",
params![workspace, id, tag.id],
)
.unwrap();
}
conn.execute(
"INSERT INTO typed_ticket_labels VALUES(?1,?2,0,'ready')",
params![workspace, id],
)
.unwrap();
let next: i64 = conn.query_row("SELECT COALESCE(MAX(event_index)+1,0) FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![workspace,id],|row|row.get(0)).unwrap();
for (offset, key, before, after) in [
(
0,
request.operation_key.as_str(),
Vec::new(),
receipt.clone(),
),
(
1,
delete_request.operation_key.as_str(),
receipt.clone(),
active.clone(),
),
] {
let body =
serde_json::json!({"operation_key":key,"before":before,"after":after}).to_string();
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,body) VALUES(?1,?2,?3,'tags_changed','classifier','original-time',?4)",params![workspace,id,next+offset,body]).unwrap();
for (key, value) in &source {
conn.execute(
"INSERT INTO typed_ticket_event_attributes VALUES(?1,?2,?3,?4,?5)",
params![workspace, id, next + offset, key, value],
)
.unwrap();
}
}
let deleted_id = old.id.clone();
RetainedClassification {
source,
request,
receipt,
active_ids: active.iter().map(|tag| tag.id.clone()).collect(),
deleted_id,
}
}
fn classification_rows(conn: &Connection) -> Vec<Vec<Vec<rusqlite::types::Value>>> {
[
"workspace_tags",
"typed_ticket_tags",
"workspace_tag_operations",
"typed_ticket_events",
"typed_ticket_event_attributes",
"typed_ticket_labels",
]
.map(|table| stored_fixture_rows(conn, table))
.to_vec()
}
#[test]
fn workspace90_ticket7_and_tagged8_upgrade_to_cases10_preserving_history_and_auth() {
for component in [7, 8] {
assert_retained_classification_upgrade(90, component);
}
}
#[test]
fn workspace89_auth_then_cases_preserve_tags_receipts_source_context_and_runtime_archives() {
assert_retained_classification_upgrade(89, 8);
}
fn assert_retained_classification_upgrade(workspace_version: i64, component: i64) {
use ticket::TicketBackend;
let (_dir, path) = legacy_case_workspace(workspace_version);
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
// Real six-state parent rows: neither a fresh open/closed schema nor API close.
conn.execute_batch("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,resolution,created_at,updated_at)
VALUES('case-workspace','closed-legacy','closed-legacy','Retained decision','closed','task','P2','Original requirements','done',1,'Original closure text','1','2');
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,reason,state_field,body)
VALUES('case-workspace','closed-legacy',0,'state_changed','original-author','1','inprogress','done','Original decision','state','Original thread');
INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at)
VALUES('case-workspace','case-runtime','recipient','Recipient','normal','1','1');
INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,assigned_by,assigned_at)
VALUES('case-workspace','closed-legacy','assignment','worker','worker','case-runtime','recipient','original-actor','1');
INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,updated_at)
VALUES('case-workspace','closed-legacy','assignment','worker','worker','case-runtime','recipient','1');").unwrap();
ticket::verify_pre_classification_ticket_schema(&conn).unwrap();
let retained = if component == 8 {
install_retained_ticket_tags8(&conn);
Some(seed_retained_classification(
&conn,
"case-workspace",
"closed-legacy",
))
} else {
conn.execute(
"INSERT INTO typed_ticket_labels VALUES('case-workspace','closed-legacy',0,'ready')",
[],
)
.unwrap();
None
};
assert_eq!(
conn.query_row(
"SELECT MAX(version) FROM ticket_schema_migrations",
[],
|row| row.get::<_, i64>(0)
)
.unwrap(),
component
);
assert!(!table_exists(&conn, "typed_ticket_case_history").unwrap());
let preserved_tables = [
"typed_ticket_events",
"typed_ticket_event_attributes",
"typed_ticket_labels",
"ticket_current_worker_assignments",
"ticket_worker_assignments",
"workspace_signing_identities",
"workspace_signing_identity_audit",
"workspace_signing_identity_provisioning_operations",
"workspace_runtime_binding_audit",
];
let preserved = preserved_tables.map(|table| stored_fixture_rows(&conn, table));
let tags_before = retained.as_ref().map(|_| classification_rows(&conn));
let auth_before = fixture_auth_rows(&conn);
drop(conn);
let store = SqliteWorkspaceStore::open(&path).unwrap();
store.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?,91);
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,10);
assert_eq!(fixture_auth_rows(conn),auth_before);
for (table, rows) in preserved_tables.iter().zip(&preserved) { assert_eq!(&stored_fixture_rows(conn,table),rows,"preserve {table}"); }
if let Some(rows)=&tags_before {assert_eq!(&classification_rows(conn),rows);}
assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_work_releases WHERE assignment_id='assignment'",[],|row|row.get::<_,i64>(0))?,1);
assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check",[],|row|row.get::<_,i64>(0))?,0);
assert!(!column_exists(conn,"workspace_runtime_bindings","state")?);
ticket::verify_sqlite_ticket_schema(conn).unwrap();
Ok(())
}).unwrap();
let backend = ticket::SqliteTicketBackend::open_verified(&path, "case-workspace").unwrap();
let before = backend.show("closed-legacy".into()).unwrap();
assert_eq!(before.meta.workflow_state, ticket::TicketState::Closed);
assert_eq!(
before.current_closure.as_ref().unwrap().reason,
ticket::TicketClosureReason::Unknown
);
assert_eq!(before.case_history.len(), 1);
assert_eq!(
before
.legacy_workflow_snapshot
.as_ref()
.unwrap()
.resolution
.as_deref(),
Some("Original closure text")
);
if let Some(retained) = retained {
let mut changed_context = retained.source.clone();
changed_context.insert("source_assignment_id".into(), "new-assignment".into());
changed_context.insert("assigned_worker_id".into(), "new-recipient".into());
let actor = backend
.clone()
.with_event_attributes(changed_context.clone());
assert_eq!(
actor
.set_ticket_tags("closed-legacy".into(), retained.request.clone())
.unwrap(),
retained.receipt
);
changed_context.insert("source_worker_id".into(), "other-classifier".into());
assert!(
backend
.clone()
.with_event_attributes(changed_context)
.set_ticket_tags("closed-legacy".into(), retained.request)
.is_err()
);
store
.with_conn(|conn| {
assert_eq!(classification_rows(conn), tags_before.clone().unwrap());
assert!(
conn.execute(
"INSERT INTO typed_ticket_tags VALUES('other','missing',?1)",
[&retained.active_ids[0]]
)
.is_err()
);
Ok(())
})
.unwrap();
assert!(
backend
.set_ticket_tags(
"closed-legacy".into(),
ticket::TicketTagSetRequest {
operation_key: "deleted-reference".into(),
expected_tag_ids: retained.active_ids.clone(),
tag_ids: vec![retained.deleted_id]
}
)
.is_err()
);
}
// Arbitrary lifecycle-looking tag names cannot alter a reasoned closure,
// digest, assignment or prior immutable case history.
let reasoned = update_test_case(
&backend,
"closed-legacy",
"correct-reason",
ticket::TicketCaseAction::CorrectClosure {
reason: ticket::TicketClosureReason::NotPlanned,
},
);
let tag = backend
.mutate_workspace_tag(ticket::WorkspaceTagMutation {
operation_key: "new-ready".into(),
action: ticket::WorkspaceTagAction::Create {
name: "ready after upgrade".into(),
},
})
.unwrap();
backend
.set_ticket_tags(
"closed-legacy".into(),
ticket::TicketTagSetRequest {
operation_key: "new-attach".into(),
expected_tag_ids: reasoned.tags.iter().map(|tag| tag.id.clone()).collect(),
tag_ids: vec![tag.id],
},
)
.unwrap();
let classified = backend.show("closed-legacy".into()).unwrap();
assert_eq!(classified.meta, reasoned.meta);
assert_eq!(classified.current_closure, reasoned.current_closure);
assert_eq!(classified.case_history, reasoned.case_history);
assert_eq!(
ticket::ticket_content_digest(&classified),
ticket::ticket_content_digest(&reasoned)
);
let tag_history: Vec<_> = classified
.events
.iter()
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
.cloned()
.collect();
let reopened = update_test_case(
&backend,
"closed-legacy",
"reopen",
ticket::TicketCaseAction::Reopen,
);
assert_eq!(reopened.tags, classified.tags);
assert_eq!(
reopened.case_history[..classified.case_history.len()],
classified.case_history
);
assert_eq!(
reopened
.events
.iter()
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
.cloned()
.collect::<Vec<_>>(),
tag_history
);
assert!(
store
.get_active_ticket_worker_assignment("case-workspace", "closed-legacy")
.unwrap()
.is_none()
);
}
// Frozen cases-only component9 DDL from a558, not a stripped current DB.
const RETAINED_CASES9_SQL: &str = r#"
-- 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;
"#;
#[test]
fn workspace90_cases9_add_classification_without_reinterpreting_reasoned_history() {
use ticket::TicketBackend;
let (_dir, path) = legacy_case_workspace(90);
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
// Build a genuine cases-only boundary from retained7 and frozen9 DDL.
// No latest schema is relabelled and no six-state row is called current.
conn.execute_batch(RETAINED_CASES9_SQL).unwrap();
conn.execute_batch("UPDATE ticket_schema_migrations SET version=9;
INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
VALUES('case-workspace','reasoned','reasoned','Existing judgment','closed','task','P2','Original requirements','closed',1);
INSERT INTO typed_ticket_case_metadata VALUES('case-workspace','reasoned','original-closure');").unwrap();
let judgment = ticket::TicketCaseHistoryEntry {
operation_key: "original-closure".into(),
previous_closure_operation_key: None,
action: ticket::TicketCaseAction::Close {
reason: ticket::TicketClosureReason::Completed,
},
explanation: "Original reasoned conclusion".into(),
references: vec![ticket::TicketReference {
kind: "commit".into(),
target: "original-source".into(),
}],
author: "original-author".into(),
at: "original-time".into(),
};
conn.execute("INSERT INTO typed_ticket_case_history VALUES('case-workspace','reasoned',0,'original-closure','original-fingerprint',?1)",
[serde_json::to_string(&judgment).unwrap()]).unwrap();
assert!(!table_exists(&conn, "workspace_tags").unwrap());
let history_before = stored_fixture_rows(&conn, "typed_ticket_case_history");
let metadata_before = stored_fixture_rows(&conn, "typed_ticket_case_metadata");
let auth_before = fixture_auth_rows(&conn);
drop(conn);
let store = SqliteWorkspaceStore::open(&path).unwrap();
store
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 91);
assert_eq!(
conn.query_row(
"SELECT MAX(version) FROM ticket_schema_migrations",
[],
|row| row.get::<_, i64>(0)
)?,
10
);
assert_eq!(
stored_fixture_rows(conn, "typed_ticket_case_history"),
history_before
);
assert_eq!(
stored_fixture_rows(conn, "typed_ticket_case_metadata"),
metadata_before
);
assert_eq!(fixture_auth_rows(conn), auth_before);
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
row.get::<_, i64>(0)
})?,
0
);
ticket::verify_sqlite_ticket_schema(conn).unwrap();
Ok(())
})
.unwrap();
let backend = ticket::SqliteTicketBackend::open_verified(&path, "case-workspace").unwrap();
let before = backend.show("reasoned".into()).unwrap();
assert_eq!(before.case_history, vec![judgment]);
assert_eq!(
before.current_closure.as_ref().unwrap().reason,
ticket::TicketClosureReason::Completed
);
for name in ["ready", "closed"] {
let tag = backend
.mutate_workspace_tag(ticket::WorkspaceTagMutation {
operation_key: format!("create-{name}"),
action: ticket::WorkspaceTagAction::Create { name: name.into() },
})
.unwrap();
let current = backend.show("reasoned".into()).unwrap();
let mut desired: Vec<_> = current.tags.iter().map(|tag| tag.id.clone()).collect();
desired.push(tag.id);
backend
.set_ticket_tags(
"reasoned".into(),
ticket::TicketTagSetRequest {
operation_key: format!("attach-{name}"),
expected_tag_ids: current.tags.iter().map(|tag| tag.id.clone()).collect(),
tag_ids: desired,
},
)
.unwrap();
}
let after = backend.show("reasoned".into()).unwrap();
assert_eq!(after.meta, before.meta);
assert_eq!(after.current_closure, before.current_closure);
assert_eq!(after.case_history, before.case_history);
assert_eq!(
ticket::ticket_content_digest(&after),
ticket::ticket_content_digest(&before)
);
}
@@ -65,6 +65,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"typed_ticket_relations",
"typed_ticket_risk_flags",
"typed_ticket_targets",
"typed_ticket_tags",
"typed_tickets",
"workdir_create_credential_candidates",
"workdir_create_credential_retentions",
@@ -96,6 +97,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"workspace_config_tree_history",
"workspace_config_trees",
"workspace_create_operations",
"workspace_tag_operations",
"workspace_tags",
"workspace_memory_documents",
"workspace_memory_settings",
"workspace_resource_key_counters",