feat(ticket): expose case tags and independent assignment handling

This commit is contained in:
2026-10-12 07:33:11 +09:00
parent 56795fb92c
commit 9b0d42fdbc
79 changed files with 9326 additions and 713 deletions
+117
View File
@@ -0,0 +1,117 @@
//! Continuing input is authored against an observed delegation, never a new Assign.
use super::*;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketAssignmentAnswer {
pub assignment_id: String,
pub expected_last_event_id: String,
pub operation_key: String,
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TicketAssignmentAnswerReceipt {
pub assignment_id: String,
pub input_event_id: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub event_index: u64,
}
/// Host owns the IMMEDIATE transaction and authenticates/fences its source before
/// calling, including on replay. Reuse Ticket's existing immutable operation
/// receipts with a tagged input; these are not handling events or another ledger.
pub fn sqlite_answer_ticket_assignment_in_transaction(
conn: &Connection,
workspace_id: &str,
ticket_id: &str,
answer: &TicketAssignmentAnswer,
source_context: &serde_json::Value,
actor: &str,
) -> Result<TicketAssignmentAnswerReceipt> {
for value in [
&answer.assignment_id,
&answer.expected_last_event_id,
&answer.operation_key,
actor,
] {
if value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control) {
return Err(TicketError::Conflict(
"Answer requires bounded nonempty identities".into(),
));
}
}
if answer.body.trim().is_empty() || answer.body.len() > 16384 || answer.body.contains('\0') {
return Err(TicketError::Conflict(
"Answer body must be nonempty, at most 16384 bytes and without NUL".into(),
));
}
let input =
serde_json::to_string(&serde_json::json!({"kind":"assignment_answer", "answer":answer}))
.map_err(sqlite_err)?;
let source = serde_json::to_string(
&serde_json::json!({"kind":"assignment_answer", "source":source_context, "actor":actor}),
)
.map_err(sqlite_err)?;
let receipt: Option<(String,String,String,String)> = conn.query_row(
"SELECT ticket_id,input_json,source_context_json,result_json FROM typed_ticket_assignment_tracking_operations WHERE workspace_id=?1 AND operation_key=?2",
params![workspace_id, answer.operation_key], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).optional().map_err(sqlite_err)?;
if let Some((saved_ticket, saved_input, saved_source, saved_result)) = receipt {
if saved_ticket != ticket_id || saved_input != input || saved_source != source {
return Err(TicketError::OperationFingerprintMismatch {
operation_key: answer.operation_key.clone(),
});
}
return serde_json::from_str(&saved_result).map_err(sqlite_err);
}
let current =
sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, &answer.assignment_id)?
.ok_or_else(|| {
TicketError::Conflict("Answer assignment does not exist in this Ticket".into())
})?;
if !current.current
|| current.last_event_id.as_deref() != Some(&answer.expected_last_event_id)
|| !current.tracking.as_ref().is_some_and(|tracking| {
matches!(
tracking.status,
TicketAssignmentTrackingStatus::Waiting
| TicketAssignmentTrackingStatus::Inprogress
)
})
{
return Err(TicketError::Conflict("Answer observed assignment or handling event is stale; read current responsibility before authoring".into()));
}
let open: bool = conn.query_row("SELECT workflow_state='open' FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", params![workspace_id,ticket_id], |r| r.get(0)).map_err(sqlite_err)?;
if !open {
return Err(TicketError::Conflict(
"A closed Ticket does not admit continuing work input".into(),
));
}
let index: i64 = conn.query_row("SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![workspace_id,ticket_id],|r| r.get(0)).map_err(sqlite_err)?;
let at = now_utc();
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,heading,body) VALUES(?1,?2,?3,'assignment_answer',?4,?5,'Assignment answer',?6)", params![workspace_id,ticket_id,index,actor,at,answer.body]).map_err(sqlite_err)?;
for (key, value) in [
("orchestrator_assignment_id", answer.assignment_id.as_str()),
(
"expected_last_event_id",
answer.expected_last_event_id.as_str(),
),
("operation_key", answer.operation_key.as_str()),
("authenticated_source", source.as_str()),
] {
conn.execute("INSERT INTO typed_ticket_event_attributes(workspace_id,ticket_id,event_index,key,value) VALUES(?1,?2,?3,?4,?5)",params![workspace_id,ticket_id,index,key,value]).map_err(sqlite_err)?;
}
conn.execute(
"UPDATE typed_tickets SET updated_at=?3 WHERE workspace_id=?1 AND ticket_id=?2",
params![workspace_id, ticket_id, at],
)
.map_err(sqlite_err)?;
let result = TicketAssignmentAnswerReceipt {
assignment_id: answer.assignment_id.clone(),
input_event_id: format!("ticket-event:{index}"),
event_index: index as u64,
};
let result_json = serde_json::to_string(&result).map_err(sqlite_err)?;
conn.execute("INSERT INTO typed_ticket_assignment_tracking_operations(workspace_id,operation_key,ticket_id,input_json,source_context_json,result_json) VALUES(?1,?2,?3,?4,?5,?6)",params![workspace_id,answer.operation_key,ticket_id,input,source,result_json]).map_err(sqlite_err)?;
Ok(result)
}
+50
View File
@@ -898,3 +898,53 @@ pub(crate) fn verify_assignment_tracking_schema(conn: &Connection) -> Result<()>
#[cfg(test)]
#[path = "assignment_tracking_tests.rs"]
mod tests;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TicketAssignmentsPage {
pub items: Vec<TicketAssignmentTrackingProjection>,
pub next_assignment_id: Option<String>,
}
/// Immutable append-order identities. The cursor must be an identity in this
/// Ticket/Workspace; timestamps and lexical IDs are not insertion order.
pub fn sqlite_ticket_assignments_page(
conn: &Connection,
workspace_id: &str,
ticket_id: &str,
after_assignment_id: Option<&str>,
limit: usize,
) -> Result<TicketAssignmentsPage> {
if !(1..=100).contains(&limit) {
return Err(TicketError::Conflict(
"Assignment page limit must be 1..100".into(),
));
}
let after: i64 = match after_assignment_id {
None => 0,
Some(id) => conn.query_row("SELECT rowid FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='orchestrator' AND assignment_id=?3",params![workspace_id,ticket_id,id],|r| r.get(0)).optional().map_err(sqlite_err)?
.ok_or_else(|| TicketError::Conflict("Assignment cursor does not belong to this Ticket".into()))?,
};
let mut stmt = conn.prepare("SELECT assignment_id FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='orchestrator' AND rowid>?3 ORDER BY rowid LIMIT ?4").map_err(sqlite_err)?;
let mut ids = stmt
.query_map(
params![workspace_id, ticket_id, after, (limit + 1) as i64],
|r| r.get::<_, String>(0),
)
.map_err(sqlite_err)?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(sqlite_err)?;
let has_more = ids.len() > limit;
ids.truncate(limit);
let next_assignment_id = if has_more { ids.last().cloned() } else { None };
let items = ids
.iter()
.map(|id| {
sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, id)?
.ok_or_else(|| TicketError::Conflict("Assignment disappeared while paging".into()))
})
.collect::<Result<Vec<_>>>()?;
Ok(TicketAssignmentsPage {
items,
next_assignment_id,
})
}
@@ -728,3 +728,104 @@ fn schema_verification_does_not_normalize_away_case_sensitive_authority_literals
.contains("differs from canonical schema")
);
}
#[test]
fn assignment_answer_commits_one_audited_input_and_exact_receipt_without_tracking_mutation() {
let conn = db();
assign(&conn, "a", "assigned-a");
let answer = TicketAssignmentAnswer {
assignment_id: "a".into(),
expected_last_event_id: "assigned-a".into(),
operation_key: "answer-a".into(),
body: "Continue with B".into(),
};
let source = serde_json::json!({"kind":"account","account_id":"account","user_id":"human"});
let apply = |answer: &TicketAssignmentAnswer| {
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
let result = sqlite_answer_ticket_assignment_in_transaction(
&conn, "w", "t", answer, &source, "human",
);
conn.execute_batch(if result.is_ok() { "COMMIT" } else { "ROLLBACK" })
.unwrap();
result
};
let result = apply(&answer).unwrap();
assert_eq!(result.input_event_id, "ticket-event:0");
assert_eq!(apply(&answer).unwrap(), result);
assert_eq!(
conn.query_row("SELECT count(*) FROM typed_ticket_events", [], |r| r
.get::<_, i64>(0))
.unwrap(),
1
);
assert_eq!(
conn.query_row("SELECT body FROM typed_ticket_events", [], |r| r
.get::<_, String>(0))
.unwrap(),
answer.body
);
assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE key='orchestrator_assignment_id'",[],|r| r.get::<_,String>(0)).unwrap(),"a");
assert_eq!(
sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
.unwrap()
.unwrap()
.last_event_id
.as_deref(),
Some("assigned-a")
);
let mut altered = answer.clone();
altered.body = "Changed".into();
assert!(apply(&altered).is_err());
assign(&conn, "b", "assigned-b");
assert_eq!(apply(&answer).unwrap(), result);
altered.operation_key = "fresh-stale".into();
assert!(apply(&altered).is_err());
}
#[test]
fn assignment_answer_rejects_unbounded_body_and_malformed_identities_without_appending_input() {
let conn = db();
assign(&conn, "a", "assigned-a");
let original = TicketAssignmentAnswer {
assignment_id: "a".into(),
expected_last_event_id: "assigned-a".into(),
operation_key: "answer-a".into(),
body: "Answer".into(),
};
for field in [
"body-large",
"body-nul",
"body-empty",
"identity-control",
"event-empty",
] {
let mut answer = original.clone();
match field {
"body-large" => answer.body = "a".repeat(16385),
"body-nul" => answer.body = "answer\0hidden".into(),
"body-empty" => answer.body = " ".into(),
"identity-control" => answer.operation_key = "key\n".into(),
_ => answer.expected_last_event_id = "".into(),
}
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
assert!(
sqlite_answer_ticket_assignment_in_transaction(
&conn,
"w",
"t",
&answer,
&serde_json::json!({"actor":"source"}),
"human"
)
.is_err(),
"{field}"
);
conn.execute_batch("ROLLBACK").unwrap();
}
assert_eq!(
conn.query_row("SELECT count(*) FROM typed_ticket_events", [], |r| r
.get::<_, i64>(0))
.unwrap(),
0
);
}
+2
View File
@@ -20,6 +20,8 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
mod assignment_answer;
pub use assignment_answer::*;
mod assignment_tracking;
pub mod config;
mod sqlite_schema;